From df94d921ef8b68f205ebe863867dca8793f96aa6 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 16:45:04 +0200 Subject: [PATCH] feat(11-06): TenderSavedSearch model + CRUD service (FILTER-06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GREEN phase — adds TenderSavedSearch (userId+tenantId scoped, filters Json, @@unique([userId,name])), the migration (applied to local dev DB), and TenderSavedSearchService following the FavoritesService/ TenderTriageService pattern: manual where:{userId} scoping (no forTenant()/RLS), ownership check before update/remove, P2002 unique conflicts translated to ConflictException. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration.sql | 25 ++++ apps/api/prisma/schema.prisma | 20 ++++ apps/api/src/tenders/dto/saved-search.dto.ts | 41 +++++++ .../tenders/tender-saved-search.service.ts | 112 ++++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 apps/api/prisma/migrations/20260721170000_add_tender_saved_search/migration.sql create mode 100644 apps/api/src/tenders/dto/saved-search.dto.ts create mode 100644 apps/api/src/tenders/tender-saved-search.service.ts diff --git a/apps/api/prisma/migrations/20260721170000_add_tender_saved_search/migration.sql b/apps/api/prisma/migrations/20260721170000_add_tender_saved_search/migration.sql new file mode 100644 index 0000000..2a3ab9c --- /dev/null +++ b/apps/api/prisma/migrations/20260721170000_add_tender_saved_search/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +-- FILTER-06 (D-08/D-11) — per-user Suchprofil (benannte Filterkombination). +-- Scoping-Muster wie FavoriteLink/TenderTriage (T-08-06, Pitfall 4): +-- userId-Scoping im Service, KEIN forTenant()/RLS. tenantId wird +-- zusätzlich mitgeführt (spätere Tenant-Isolation), ist aber nicht das +-- Scoping-Feld. KEIN Tender-FK (Pitfall 6) — filters speichert nur +-- Kriterien, nicht Tender-Ids, daher unkritisch bei der Retention. +CREATE TABLE IF NOT EXISTS "TenderSavedSearch" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "filters" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TenderSavedSearch_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +-- Verhindert zwei Profile gleichen Namens pro Nutzer (T-11-14). +CREATE UNIQUE INDEX IF NOT EXISTS "TenderSavedSearch_userId_name_key" ON "TenderSavedSearch"("userId", "name"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "TenderSavedSearch_userId_idx" ON "TenderSavedSearch"("userId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 351f472..c4ae466 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -316,6 +316,26 @@ model TenderTriage { @@index([tenderId]) } +// FILTER-06 — per-user Suchprofil (D-08/D-11). Scoping-Muster wie +// FavoriteLink/TenderTriage (T-08-06, Pitfall 4): userId-Scoping im +// Service, KEIN forTenant()/RLS. tenantId wird zusätzlich mitgeführt +// (spätere Tenant-Isolation), ist aber NICHT das Scoping-Feld. KEIN +// Tender-FK (Pitfall 6) — ein Profil speichert nur die Filterkriterien +// (deckungsgleich mit den URL-searchParams, Plan 11-06), nicht Tender-Ids, +// daher unkritisch bei der 90-Tage-Retention. +model TenderSavedSearch { + id String @id @default(uuid()) + userId String + tenantId String + name String + filters Json // serialisierte Filterkombination (q, plz, bundesland, region, cpv, deadlineFrom/To, openOnly, valueMin/Max, includeNullValue, sort, favOnly) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, name]) // keine zwei Profile gleichen Namens pro Nutzer + @@index([userId]) +} + // Singleton-per-source admin poll config (INGEST-06 foundation). model TenderSourcePollConfig { id String @id @default(uuid()) diff --git a/apps/api/src/tenders/dto/saved-search.dto.ts b/apps/api/src/tenders/dto/saved-search.dto.ts new file mode 100644 index 0000000..1846b0a --- /dev/null +++ b/apps/api/src/tenders/dto/saved-search.dto.ts @@ -0,0 +1,41 @@ +import { IsNotEmpty, IsObject, IsOptional, IsString, MaxLength } from 'class-validator'; + +/** + * Body DTO for POST /modules/tender-radar/saved-searches (FILTER-06). + * + * Security (T-11-14 / V4 — IDOR): deliberately has NO userId or tenantId + * field — both are always derived server-side from the auth context + * (FavoritesController.extractContext pattern) in TendersController, never + * trusted from the request body. + * + * `filters` is validated only as a plain object (T-11-17 / V5) — its shape + * mirrors the FilterPanel's URL-searchParams contract on the frontend, but + * the backend does not re-validate individual filter keys here; Prisma + * stores it as parametrized JSONB (no string interpolation, V5). + */ +export class CreateSavedSearchDto { + @IsString() + @IsNotEmpty() + @MaxLength(100) + name!: string; + + @IsObject() + filters!: Record; +} + +/** + * Body DTO for PATCH /modules/tender-radar/saved-searches/:searchId. + * Both fields optional — only provided fields are updated (FavoritesService + * UpdateFavoriteDto pattern). + */ +export class UpdateSavedSearchDto { + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsObject() + filters?: Record; +} diff --git a/apps/api/src/tenders/tender-saved-search.service.ts b/apps/api/src/tenders/tender-saved-search.service.ts new file mode 100644 index 0000000..721b459 --- /dev/null +++ b/apps/api/src/tenders/tender-saved-search.service.ts @@ -0,0 +1,112 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto'; + +/** + * Service for managing per-user Tender saved searches (Suchprofile, + * FILTER-06, D-08/D-11). + * + * Access control (T-11-14 / V4 — IDOR): every query is scoped by userId, + * exactly the FavoritesService/TenderTriageService convention (T-08-06) — + * NOT forTenant()/RLS (Pitfall 4). userId must always be derived from the + * caller's auth context (controller), never accepted as a body/query + * parameter here. + * + * @@unique([userId, name]) (T-11-14): a second profile with the same name + * for the same user is rejected by Postgres (P2002) — this service + * translates that into a 409 ConflictException so the frontend can show a + * meaningful "Name bereits vergeben" message instead of a raw 500. + */ +@Injectable() +export class TenderSavedSearchService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Returns all saved searches for a user, ordered by name asc. Scoped + * strictly by userId (V4/IDOR) — a foreign userId sees nothing. + */ + async list(userId: string) { + return this.prisma.tenderSavedSearch.findMany({ + where: { userId }, + orderBy: { name: 'asc' }, + }); + } + + /** + * Creates a new saved search. Throws ConflictException (409) if this + * user already has a profile with the same name + * (@@unique([userId,name])) — the same name IS allowed across different + * users, since the uniqueness is scoped per-user. + */ + async create(userId: string, tenantId: string, dto: CreateSavedSearchDto) { + try { + return await this.prisma.tenderSavedSearch.create({ + data: { + userId, + tenantId, + name: dto.name, + filters: dto.filters, + }, + }); + } catch (error: any) { + if (error?.code === 'P2002') { + throw new ConflictException( + 'Ein Suchprofil mit diesem Namen existiert bereits.', + ); + } + throw error; + } + } + + /** + * Updates an existing saved search (rename and/or filters change). + * Verifies userId ownership before applying changes (T-11-14) — throws + * NotFoundException for BOTH a missing row and a foreign-owned row + * (FavoritesService pattern: never distinguishes the two, to avoid + * leaking whether another user's profile exists). + */ + async update(id: string, userId: string, dto: UpdateSavedSearchDto) { + const existing = await this.prisma.tenderSavedSearch.findUnique({ + where: { id }, + }); + + if (!existing || existing.userId !== userId) { + throw new NotFoundException('Suchprofil nicht gefunden'); + } + + const data: Record = {}; + if (dto.name !== undefined) data.name = dto.name; + if (dto.filters !== undefined) data.filters = dto.filters; + + try { + return await this.prisma.tenderSavedSearch.update({ + where: { id }, + data, + }); + } catch (error: any) { + if (error?.code === 'P2002') { + throw new ConflictException( + 'Ein Suchprofil mit diesem Namen existiert bereits.', + ); + } + throw error; + } + } + + /** + * Deletes a saved search. Verifies userId ownership before deleting + * (T-11-14) — same missing-vs-foreign NotFoundException collapse as + * update(). + */ + async remove(id: string, userId: string) { + const existing = await this.prisma.tenderSavedSearch.findUnique({ + where: { id }, + }); + + if (!existing || existing.userId !== userId) { + throw new NotFoundException('Suchprofil nicht gefunden'); + } + + await this.prisma.tenderSavedSearch.delete({ where: { id } }); + } +}