From 4036991acdd1156a14e968c09c5687f5976d2d1f Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 15:17:48 +0200 Subject: [PATCH] =?UTF-8?q?docs(11):=20create=20phase=20plan=20=E2=80=94?= =?UTF-8?q?=206=20vertical-slice=20plans=20(filter=20engine,=20results=20U?= =?UTF-8?q?I,=20saved=20searches)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 8 +- .../11-01-PLAN.md | 141 ++++++++++++++++++ .../11-02-PLAN.md | 124 +++++++++++++++ .../11-03-PLAN.md | 123 +++++++++++++++ .../11-04-PLAN.md | 101 +++++++++++++ .../11-05-PLAN.md | 132 ++++++++++++++++ .../11-06-PLAN.md | 128 ++++++++++++++++ 7 files changed, 756 insertions(+), 1 deletion(-) create mode 100644 .planning/phases/11-filter-engine-results-ui-saved-searches/11-01-PLAN.md create mode 100644 .planning/phases/11-filter-engine-results-ui-saved-searches/11-02-PLAN.md create mode 100644 .planning/phases/11-filter-engine-results-ui-saved-searches/11-03-PLAN.md create mode 100644 .planning/phases/11-filter-engine-results-ui-saved-searches/11-04-PLAN.md create mode 100644 .planning/phases/11-filter-engine-results-ui-saved-searches/11-05-PLAN.md create mode 100644 .planning/phases/11-filter-engine-results-ui-saved-searches/11-06-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index c4bd437..d0ef5c6 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -386,7 +386,13 @@ Plans: 4. User can mark a tender as read/unread and as favourite, and filter the results list to only favourites -- both states are per-user, not shared across the tenant 5. The UI clearly indicates data coverage (Oberschwelle vs. Unterschwelle) so an empty or thin result set isn't mistaken for a bug -**Plans**: TBD +**Plans**: 6 plans +- [ ] 11-01-PLAN.md — Trefferliste + Freitext + Sortierung + Coverage-Banner (FILTER-01/04/05, UI-01/05) +- [ ] 11-02-PLAN.md — Bundesland-Ableitung (NUTS + Backfill) + Region/PLZ/Bundesland-Filter (FILTER-02) +- [ ] 11-03-PLAN.md — CPV-Filter (Katalog + Divisionen) + Wert-Filter-UI (FILTER-03/05) +- [ ] 11-04-PLAN.md — Detailansicht via ?tender= + Quell-Link (UI-02) +- [ ] 11-05-PLAN.md — Triage: gelesen/ungelesen + Favorit + Merklisten-Filter (UI-03/04) +- [ ] 11-06-PLAN.md — Suchprofile speichern/bearbeiten/löschen (FILTER-06) **UI hint**: yes ### Phase 12: Tender Notifications diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-01-PLAN.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-01-PLAN.md new file mode 100644 index 0000000..67e39aa --- /dev/null +++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-01-PLAN.md @@ -0,0 +1,141 @@ +--- +phase: 11-filter-engine-results-ui-saved-searches +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - apps/api/src/tenders/dto/tender-query.dto.ts + - apps/api/src/tenders/tender-query.builder.ts + - apps/api/src/tenders/tender-query.builder.spec.ts + - apps/api/src/tenders/tenders.controller.ts + - apps/api/src/tenders/tenders.controller.spec.ts + - apps/web/src/lib/tender-radar-api.ts + - apps/web/src/app/(portal)/modules/tender-radar/page.tsx + - apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx + - apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx + - apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx + - apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx +autonomous: true +requirements: [FILTER-01, FILTER-04, FILTER-05, UI-01, UI-05] + +must_haves: + truths: + - "Öffnet ein Nutzer das Ausschreibungs-Radar-Modul, sieht er statt des Platzhalters eine Liste der vorhandenen DÖE-Ausschreibungen." + - "Freitext-Eingabe filtert die Liste über Titel und Auftraggeber (case-insensitive)." + - "Die Liste ist nach Frist, Wert und Veröffentlichungsdatum sortierbar; Standard-Ansicht zeigt nur noch offene Ausschreibungen." + - "Ausschreibungen ohne Wertangabe verschwinden nie und werden als „keine Wertangabe“ markiert." + - "Ein Coverage-Banner erklärt die Datenabdeckung (Oberschwelle DÖE), damit eine dünne Liste nicht als Fehler gelesen wird." + artifacts: + - apps/api/src/tenders/tender-query.builder.ts + - apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx + - apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx + key_links: + - "FilterPanel schreibt URL-searchParams → ResultsList liest sie → listTenders() → GET /modules/tender-radar? → buildTenderWhere/buildOrderBy." + - "GET /modules/tender-radar/coverage MUSS vor @Get(':id') deklariert sein (Route-Order-Falle)." +--- + +## Phase Goal + +**As a** Ausschreibungs-Radar-Nutzer, **I want to** die vorhandenen DÖE-Ausschreibungen durchsuchen, filtern, triagieren und Filterkombinationen als persönliche Suchprofile speichern, **so that** ich relevante Vergaben finde und verfolge — und eine dünne Trefferliste dank transparenter Abdeckungs-Anzeige nicht als Fehler missverstehe. + + +Dünnster End-to-End-Slice: Der Modul-Platzhalter wird durch eine echte, serverseitig gefilterte und sortierte Trefferliste ersetzt, inkl. Freitextsuche und Coverage-Banner. Damit werden die ~1671 vorhandenen Einträge erstmals sichtbar und bedienbar. + +Purpose: Adressiert den akuten User-Frust (Modulseite zeigt aktuell NICHTS) und legt die Query-Engine + URL-Param-getriebene UI-Fundament, auf dem alle weiteren Filter-Slices aufbauen. +Output: `tender-query.builder.ts` (where + orderBy), erweitertes `TenderQueryDto`, Coverage-Endpunkt, ResultsList + FilterPanel + CoverageBanner, erweiterter API-Client. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/11-filter-engine-results-ui-saved-searches/11-CONTEXT.md +@.planning/phases/11-filter-engine-results-ui-saved-searches/11-RESEARCH.md +@apps/api/src/tenders/tenders.controller.ts +@apps/api/src/tenders/dto/tender-query.dto.ts +@apps/web/src/lib/tender-radar-api.ts +@apps/web/src/app/(portal)/modules/tender-radar/page.tsx +@apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.test.tsx + + + + + + Task 1: Query-Engine-Kern — TenderQueryDto erweitern + tender-query.builder.ts (TDD) + apps/api/src/tenders/dto/tender-query.dto.ts, apps/api/src/tenders/tender-query.builder.ts, apps/api/src/tenders/tender-query.builder.spec.ts + + - buildTenderWhere: bei leerem DTO ⇒ where.status='active', openOnly-Default true ⇒ AND enthält OR(deadlineAt gte now, deadlineAt null) — NULL-Deadlines bleiben sichtbar (D-04, FILTER-04). + - q gesetzt ⇒ AND enthält OR(title contains q insensitive, buyerName contains q insensitive) (D-01, FILTER-01). + - valueMin/valueMax gesetzt bei includeNullValue-Default true ⇒ AND enthält OR(estimatedValue range, estimatedValue null); ohne includeNullValue ⇒ reiner range (D-05, FILTER-05). Kern-Test: aktiver Wertfilter darf estimatedValue-null-Zeilen NICHT eliminieren. + - buildOrderBy: nur Keys deadline/value/published erlaubt (Whitelist), unbekannter/fehlender Key ⇒ Default publishedAt desc (UI-01, D-06). + + Erweitere `TenderQueryDto` um die validierten Filter-/Sort-Params (behalte page/limit/status/T-10-15-Bounds): q (@IsString @MaxLength(200)), openOnly (@IsBoolean, Default-Semantik true im Builder), valueMin/valueMax (@IsNumber, @Type Number), includeNullValue (@IsBoolean, Default true im Builder), sort (@IsIn ['deadline','value','published']). plz/bundesland/region/cpv/favOnly werden in späteren Plänen ergänzt — hier NICHT hinzufügen. Erstelle `tender-query.builder.ts` mit reinen Funktionen buildTenderWhere(dto): Prisma.TenderWhereInput (konditionaler AND-Aufbau exakt nach RESEARCH Pattern 1, 2, 4, 5 — für D-05 zwingend das OR mit estimatedValue null) und buildOrderBy(sort): Prisma.TenderOrderByWithRelationInput (SORT_MAP-Whitelist nach Pattern 4). Schreibe zuerst die Spec (RED), dann die Implementierung (GREEN). Erwäge Prisma nulls:'last' bei deadline/value-Sortierung. Kein Raw-SQL. Keine der negativ-getesteten Filterbedingungen als Literal in Kommentaren wiederholen. + + pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts + + Spec grün; NULL-Wert-Zeilen bleiben bei aktivem Wertfilter erhalten; Sort-Whitelist verwirft unbekannte Keys. + + + + Task 2: Controller verdrahten — listTenders über Builder + GET /coverage + apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.controller.spec.ts + Ersetze in `listTenders` das feste `where:{status}`/`orderBy:{publishedAt}` durch buildTenderWhere(query) und buildOrderBy(query.sort); Pagination (page/limit/skip/take, @Max(100)) unverändert beibehalten (Don't Hand-Roll: bestehende Bounds). Füge einen neuen statischen Handler `GET /modules/tender-radar/coverage` hinzu, der die abgedeckten Quellen liefert: `prisma.tender.groupBy({ by:['sourcePortal'], _count:true, where:{status:'active'} })` plus die Gesamtzahl, als Grundlage für den Oberschwelle/Unterschwelle-Hinweis (D-12, UI-05). KRITISCH (Pitfall 5, Route-Order-Falle): `coverage` MUSS vor `@Get(':id')` deklariert werden — exakt wie `source-config` bereits korrekt platziert ist, sonst 404-Shadowing. `coverage` mit `@UseModule('tender-radar')` gaten (Lesesurface, nicht Admin). Erweitere die Controller-Spec um: Sort-Whitelist wird angewandt, Pagination-Bounds greifen, coverage liefert distinct sourcePortal. + + pnpm --filter @tessera/api exec vitest run src/tenders/tenders.controller.spec.ts + + listTenders nutzt den Builder; GET /coverage antwortet mit Quellen-Verteilung und ist NICHT vom :id-Handler beschattet. + + + + Task 3: Frontend — Trefferliste, Freitext/Sortierung, Coverage-Banner (ersetzt Platzhalter) + apps/web/src/lib/tender-radar-api.ts, apps/web/src/app/(portal)/modules/tender-radar/page.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx + + - ResultsList rendert die von listTenders gelieferten items mit Titel, Auftraggeber, Frist, Wert und Sortier-Header (Frist/Wert/Datum). + - Eine Zeile mit estimatedValue null zeigt „keine Wertangabe“ statt eines leeren/0-Werts (D-05). + - CoverageBanner rendert den Oberschwelle-DÖE-Hinweis, wenn coverage nur `doe-opendata` meldet. + + Erweitere `tender-radar-api.ts` (Plain fetch, credentials:'include', Muster wie fetchSourceConfig — KEIN TanStack Query, D-Discretion/Research OQ4) um listTenders(params: URLSearchParams): Promise<{items,total,page,limit}>, den Tender-Typ (Felder aus dem Prisma-Modell) und fetchCoverage(). Ersetze `page.tsx` vollständig durch einen 'use client' Master-Container: liest Filter-State aus useSearchParams(), schreibt via useRouter().replace(`?${params}`); rendert CoverageBanner + FilterPanel + ResultsList; useSearchParams in eine Suspense-Grenze wrappen (Next 16 App Router). FilterPanel: Freitextfeld (schreibt q) + Sort-Auswahl (deadline/value/published) + „nur noch offene“-Toggle (openOnly, Default an, D-04). ResultsList: sortierbare Spalten/Cards; estimatedValue-null-Zeilen mit „keine Wertangabe“ markieren (D-05); Paginierung über page/limit. CoverageBanner (UI-05, D-12): kurzer deutscher Hinweistext, dass aktuell nur die DÖE-Quelle (EU-weite Oberschwelle) abgedeckt ist und Unterschwelle-Vergaben noch fehlen — damit eine dünne/leere Liste nicht als Defekt gelesen wird. Strings hardcodiert Deutsch (Stub-Konvention; i18n = Phase 14, KEINE next-intl-Keys). Vitest+Testing-Library-Test (Muster SourceConfigForm.test.tsx) für ResultsList: rendert items, zeigt „keine Wertangabe“ bei null. Keine der negativ-geprüften Strings verbatim in Kommentaren. + + pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/ResultsList.test.tsx + + Modulseite zeigt echte Liste statt „Ausschreibungen werden erfasst.“; Freitext + Sort + openOnly wirken über die URL; null-Wert-Zeilen zeigen „keine Wertangabe“; Coverage-Banner sichtbar. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → API (Filter-Query) | Nutzergesteuerte Query-Params (q, sort, value, page/limit) überschreiten die Grenze zur globalen Tender-Query. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-11-01 | Tampering | buildOrderBy / sort-Param | medium | mitigate | Sort-Whitelist (SORT_MAP, @IsIn) — keine dynamischen orderBy-Keys aus User-Input (V5). | +| T-11-02 | Denial of Service | listTenders Pagination | medium | mitigate | Bestehende Bounds beibehalten: limit @Max(100), page @Min(1) (T-10-15). | +| T-11-03 | Tampering | Prisma where-Builder | low | mitigate | Ausschließlich parametrisierte Prisma-Filter, kein Raw-SQL/String-Konkatenation (V5). | +| T-11-SC | Tampering | Paket-Installs | low | accept | Keine neuen npm-Pakete in dieser Phase (RESEARCH Package Legitimacy Audit) — kein Supply-Chain-Vektor. | + + + +- `pnpm --filter @tessera/api test` grün (Builder- + Controller-Specs). +- `pnpm --filter @tessera/web test` grün (ResultsList-Test). +- Manuell/UAT: Modul öffnen → Liste erscheint; Freitext „Bau“ filtert; Sort nach Frist ändert Reihenfolge; „nur noch offene“ aktiv als Default; eine Zeile ohne Wert zeigt „keine Wertangabe“; Coverage-Banner sichtbar. + + + +FILTER-01 (Freitext Titel/Auftraggeber), FILTER-04 (Frist + nur offene Default), FILTER-05 (Wert-Filter NULL-graceful im Builder + Markierung), UI-01 (durchsuchbare/sortierbare Liste), UI-05 (Coverage-Banner) sind end-to-end bedienbar; ROADMAP-Erfolgskriterien 1 (teilw.) und 5 erfüllt. + + + +Create `.planning/phases/11-filter-engine-results-ui-saved-searches/11-01-SUMMARY.md` when done + diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-02-PLAN.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-02-PLAN.md new file mode 100644 index 0000000..afe4ee2 --- /dev/null +++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-02-PLAN.md @@ -0,0 +1,124 @@ +--- +phase: 11-filter-engine-results-ui-saved-searches +plan: 02 +type: execute +wave: 2 +depends_on: ["11-01"] +files_modified: + - apps/api/src/tenders/geo/nuts-bundesland.ts + - apps/api/src/tenders/geo/nuts-bundesland.spec.ts + - apps/api/src/tenders/tender-normalizer.service.ts + - apps/api/src/tenders/tender-normalizer.service.spec.ts + - apps/api/prisma/schema.prisma + - apps/api/prisma/migrations/20260721140000_tender_bundesland_backfill/migration.sql + - apps/api/src/tenders/dto/tender-query.dto.ts + - apps/api/src/tenders/tender-query.builder.ts + - apps/api/src/tenders/tender-query.builder.spec.ts + - apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx +autonomous: true +requirements: [FILTER-02] + +must_haves: + truths: + - "Der Bundesland-Filter liefert echte Treffer, weil bundesland aus dem NUTS-1-Präfix von region abgeleitet wird." + - "Neu eingehende Ausschreibungen bekommen im Normalizer sofort ein bundesland gesetzt." + - "Die ~1671 Bestandszeilen werden per Backfill-Migration mit bundesland gefüllt." + - "Nutzer kann nach PLZ, Region und Bundesland (Dropdown der 16 Länder) filtern." + artifacts: + - apps/api/src/tenders/geo/nuts-bundesland.ts + - apps/api/prisma/migrations/20260721140000_tender_bundesland_backfill/migration.sql + key_links: + - "region (NUTS) → bundeslandFromRegion() → Tender.bundesland (Normalizer + Backfill) → Filter where.bundesland." +--- + + +Macht den Bundesland-Filter überhaupt erst funktionsfähig: `bundesland` ist in der Live-DB zu 100 % NULL (Phase-10-Normalizer hat NUTS→Bundesland ausdrücklich hierher vertagt). Dieser Slice leitet Bundesland aus dem `region`-NUTS-1-Präfix ab (16-Länder-Map), zieht Bestand per Backfill nach und schaltet den Region/PLZ/Bundesland-Filter frei. + +Purpose: Ohne Ableitung + Backfill ist der Bundesland-Filter tot (konstant 0 Treffer, sieht wie Bug aus — Pitfall 1). +Output: NUTS-1→Bundesland-Map, angepasster Normalizer, Backfill-Migration, erweiterter Query-Builder/DTO, FilterPanel-Erweiterung. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/11-filter-engine-results-ui-saved-searches/11-CONTEXT.md +@.planning/phases/11-filter-engine-results-ui-saved-searches/11-RESEARCH.md +@apps/api/src/tenders/tender-normalizer.service.ts +@apps/api/src/tenders/tender-query.builder.ts +@apps/api/prisma/schema.prisma + + + + + + Task 1: NUTS-1 → Bundesland-Map (geo/nuts-bundesland.ts, TDD) + apps/api/src/tenders/geo/nuts-bundesland.ts, apps/api/src/tenders/geo/nuts-bundesland.spec.ts + + - bundeslandFromRegion('DE27B…') ⇒ 'Bayern' (DE2); alle 16 NUTS-1-Präfixe (DE1..DEG) mappen korrekt. + - bundeslandFromRegion(null/undefined/'') ⇒ null (17 %+ region NULL — kein Crash). + - nutsPrefixFor('Bayern') ⇒ 'DE2' (Reverse für region-startsWith-Filter). + + Erstelle `geo/nuts-bundesland.ts` mit der festen NUTS1_BUNDESLAND-Map (16 Einträge, DE1=Baden-Württemberg … DEG=Thüringen — exakt nach RESEARCH Code Examples), bundeslandFromRegion(region) (Präfix DE+1 Zeichen, uppercase, null-safe) und der Reverse-Funktion nutsPrefixFor(bundeslandName). Validiere die Zuordnung gegen 2-3 echte region-Stichproben aus der DB (Assumption A1). Zuerst Spec (RED) inkl. NULL-region-Fall, dann Implementierung (GREEN). Reine Datei ohne Nest-Abhängigkeiten. + + pnpm --filter @tessera/api exec vitest run src/tenders/geo/nuts-bundesland.spec.ts + + Alle 16 Länder + NULL-region getestet grün; Reverse liefert korrektes Präfix. + + + + Task 2: Normalizer setzt bundesland + Backfill-Migration für Bestand + apps/api/src/tenders/tender-normalizer.service.ts, apps/api/src/tenders/tender-normalizer.service.spec.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260721140000_tender_bundesland_backfill/migration.sql + Ersetze im Normalizer die vertagte Zuweisung (`bundesland = null`) durch bundeslandFromRegion(region) (D-02, FILTER-02, Pitfall 1) — für neue Ingests greift die Ableitung damit sofort. Ergänze die Normalizer-Spec um einen Fall region→bundesland und region=null→bundesland=null. Ergänze in `schema.prisma` einen Index `@@index([bundesland])` am Tender-Modell (Filter-Performance). Erstelle die handgeschriebene Backfill-Migration `20260721140000_tender_bundesland_backfill/migration.sql`: (a) `CREATE INDEX` für bundesland, (b) idempotenter UPDATE, der für die ~1671 Bestandszeilen bundesland aus dem region-NUTS-1-Präfix setzt (CASE über die 16 Präfixe DE1..DEG bzw. via left(region,3)), nur wo bundesland IS NULL AND region IS NOT NULL. Migration lokal nach MEMORY-Hinweis anwenden (DB hat keinen Host-Port: `docker exec tessera-ctl-db-1 psql -U tessera -d tessera` oder Container-IP + tessera:tessera_dev) und `prisma generate` neu ausführen. Kein Docker-Deploy auf dem Testserver. + + pnpm --filter @tessera/api exec vitest run src/tenders/tender-normalizer.service.spec.ts + + Normalizer setzt bundesland aus region; Migration füllt Bestand (Verifikation: SELECT count(*) WHERE bundesland IS NOT NULL > 0); Index vorhanden. + + + + Task 3: Region/PLZ/Bundesland-Filter — Builder + DTO + FilterPanel + apps/api/src/tenders/dto/tender-query.dto.ts, apps/api/src/tenders/tender-query.builder.ts, apps/api/src/tenders/tender-query.builder.spec.ts, apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx + Erweitere `TenderQueryDto` um plz (@IsString @MaxLength(5)), region (@IsString) und bundesland (@IsString). Erweitere buildTenderWhere um die konditionalen Branches (nur anhängen wenn gesetzt): plz ⇒ { plz: { startsWith } }; bundesland ⇒ nach Backfill { bundesland } (indexiert); region ⇒ { region: { startsWith } } (D-02, FILTER-02, Pattern 1). Ergänze die Builder-Spec um plz/bundesland-Fälle. Erweitere `FilterPanel.tsx` um ein PLZ-Feld und ein Bundesland-Dropdown, dessen Optionen die 16 Namen aus NUTS1_BUNDESLAND sind (im Web als kleine Konstante spiegeln oder aus einem geteilten Modul beziehen); Auswahl schreibt in die URL-searchParams (plz/bundesland). Strings hardcodiert Deutsch. Diese Datei ergänzt die in 11-01 angelegte FilterPanel — bestehende Felder (Freitext/Sort/openOnly) erhalten. + + pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts + + Bundesland-Dropdown zeigt 16 Länder; Auswahl filtert die Liste auf echte Treffer; PLZ-Präfixfilter wirkt. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → API (Geo-Filter) | plz/region/bundesland-Params überschreiten die Grenze zur globalen Tender-Query. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-11-04 | Tampering | Geo-Filter-Params | low | mitigate | class-validator @IsString/@MaxLength; Prisma-parametrisierte startsWith/equals-Filter, kein Raw-SQL (V5). | +| T-11-05 | Tampering | Backfill-Migration SQL | low | mitigate | Idempotenter UPDATE nur auf abgeleiteten Feldern; keine User-Eingabe in der Migration. | +| T-11-SC | Tampering | Paket-Installs | low | accept | Keine neuen npm-Pakete (RESEARCH Package Audit). | + + + +- `pnpm --filter @tessera/api test` grün (nuts-bundesland, normalizer, builder). +- DB-Check: `SELECT bundesland, count(*) FROM "Tender" GROUP BY bundesland` zeigt gefüllte Länder statt nur NULL. +- Manuell/UAT: Bundesland-Dropdown wählen → Liste zeigt nur Treffer dieses Landes; PLZ-Präfix filtert. + + + +FILTER-02 (Region/PLZ/Bundesland) end-to-end funktionsfähig; Bundesland-Filter liefert echte Treffer statt konstant 0 (Pitfall 1 behoben); ROADMAP-Erfolgskriterium 1 (Teilaspekt Region/Bundesland) erfüllt. + + + +Create `.planning/phases/11-filter-engine-results-ui-saved-searches/11-02-SUMMARY.md` when done + diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-03-PLAN.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-03-PLAN.md new file mode 100644 index 0000000..7be3d01 --- /dev/null +++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-03-PLAN.md @@ -0,0 +1,123 @@ +--- +phase: 11-filter-engine-results-ui-saved-searches +plan: 03 +type: execute +wave: 3 +depends_on: ["11-02"] +files_modified: + - apps/api/src/tenders/cpv/cpv-catalog.ts + - apps/api/src/tenders/cpv/cpv-catalog.spec.ts + - apps/api/src/tenders/tender-normalizer.service.ts + - apps/api/prisma/schema.prisma + - apps/api/prisma/migrations/20260721150000_tender_cpv_divisions_backfill/migration.sql + - apps/api/src/tenders/dto/tender-query.dto.ts + - apps/api/src/tenders/tender-query.builder.ts + - apps/api/src/tenders/tender-query.builder.spec.ts + - apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx +autonomous: true +requirements: [FILTER-03, FILTER-05] + +must_haves: + truths: + - "Nutzer wählt eine CPV-Division/Branche mit deutschem Label per Autocomplete und filtert danach." + - "Der CPV-Filter matcht trotz inkonsistenter Formate ('45', '45000000', '45000000-7') über beidseitige Normalisierung." + - "Nutzer setzt einen Wert-min/max-Filter mit „ohne Wertangabe einschließen“-Toggle (Default an)." + artifacts: + - apps/api/src/tenders/cpv/cpv-catalog.ts + - apps/api/prisma/migrations/20260721150000_tender_cpv_divisions_backfill/migration.sql + key_links: + - "cpvCodes (inkonsistent) → normalizeCpv → cpvDivisions[] (Normalizer + Backfill) → Filter { cpvDivisions: { hasSome } }." + - "FilterPanel CPV-Autocomplete (Katalog) + Wert-min/max + includeNullValue-Toggle → URL-Params → Builder." +--- + + +Schließt die beiden verbleibenden Filter-Dimensionen ab: hierarchischer CPV-Filter mit Autocomplete und der Wert-min/max-Filter (UI). CPV wird über einen leichtgewichtigen statischen Divisions-Katalog (2-stellig, DE-Labels) plus eine normalisierte `cpvDivisions`-Spalte gelöst — damit entfällt Raw-SQL und der Match funktioniert über die inkonsistenten Quellformate. + +Purpose: FILTER-03 sauber und typsicher (hasSome, GIN-fähig) statt exaktem Match, der Baulose übersieht (Pitfall 2). FILTER-05-UI macht das im Builder bereits vorhandene NULL-graceful-Verhalten (11-01) bedienbar. +Output: CPV-Katalog + normalizeCpv, cpvDivisions-Spalte + Backfill, CPV/Wert-Filter im Builder + FilterPanel. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/11-filter-engine-results-ui-saved-searches/11-CONTEXT.md +@.planning/phases/11-filter-engine-results-ui-saved-searches/11-RESEARCH.md +@apps/api/src/tenders/tender-normalizer.service.ts +@apps/api/src/tenders/tender-query.builder.ts +@apps/api/prisma/schema.prisma + + + + + + Task 1: Statischer CPV-Divisions-Katalog + normalizeCpv (TDD) + apps/api/src/tenders/cpv/cpv-catalog.ts, apps/api/src/tenders/cpv/cpv-catalog.spec.ts + + - normalizeCpv('45000000-7') ⇒ '45000000'; divisionOf('45000000') ⇒ '45'; divisionOf('45') ⇒ '45' (beidseitige Normalisierung auf führende 2 Ziffern, Pitfall 2). + - CPV_DIVISIONS enthält die 2-stelligen Divisionen mit deutschen Labels (z. B. '45' → 'Bauarbeiten'); Lookup/Autocomplete liefert Label zu Code. + - Präfix-Match: cpvMatchesDivisions(['45000000-7','71000000'], ['45']) ⇒ true. + + Erstelle `cpv/cpv-catalog.ts`: statischer Divisions-Katalog (2-stellige CPV-Divisionen, ca. 45 Einträge, deutsche Labels — gegen die offizielle EU-CPV-Liste verifizieren, Assumption A2/tertiäre Quelle) als reine Daten + Helfer normalizeCpv(raw) (Prüfziffer '-x' abschneiden, auf Ziffern reduzieren), divisionOf(code) (führende 2 Ziffern) und cpvMatchesDivisions(codes[], selectedDivisions[]). KEIN EU-Vollkatalog (Research OQ2 — Kurzkatalog genügt, D-03). Zuerst Spec (RED) über die drei Beispielformate, dann Implementierung (GREEN). + + pnpm --filter @tessera/api exec vitest run src/tenders/cpv/cpv-catalog.spec.ts + + Normalisierung über alle drei Formate grün; Divisions-Labels vorhanden. + + + + Task 2: cpvDivisions-Spalte — Normalizer + Backfill-Migration + apps/api/src/tenders/tender-normalizer.service.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260721150000_tender_cpv_divisions_backfill/migration.sql + Ergänze am Tender-Modell `cpvDivisions String[] @default([])` plus `@@index([cpvDivisions], type: Gin)` (typsicherer, indexierbarer Präfix-Filter statt Raw-SQL — RESEARCH Pattern 3 Option B). Im Normalizer: cpvDivisions aus den cpvCodes ableiten (distinct divisionOf(normalizeCpv(code))) — greift für neue Ingests. Erstelle die Backfill-Migration `20260721150000_tender_cpv_divisions_backfill/migration.sql`: Spalte + GIN-Index anlegen und für die Bestandszeilen cpvDivisions aus cpvCodes füllen (SQL: distinct left(regexp_replace(unnest,'\\D','','g'),2) je Zeile, aggregiert), idempotent, nur wo cpvDivisions leer ist. Migration lokal anwenden (docker exec psql / Container-IP, MEMORY-Hinweis) + `prisma generate`. Kein Docker-Deploy auf dem Testserver. + + pnpm --filter @tessera/api exec vitest run src/tenders/tender-normalizer.service.spec.ts + + cpvDivisions in Schema + DB vorhanden; Bestand gefüllt (SELECT: Zeilen mit nicht-leerem cpvDivisions > 0); Normalizer setzt sie für neue Zeilen. + + + + Task 3: CPV- + Wert-Filter — Builder, DTO, FilterPanel-Autocomplete + apps/api/src/tenders/dto/tender-query.dto.ts, apps/api/src/tenders/tender-query.builder.ts, apps/api/src/tenders/tender-query.builder.spec.ts, apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx + Erweitere `TenderQueryDto` um cpv (@IsArray @IsString each — ausgewählte Divisionen). Erweitere buildTenderWhere um den CPV-Branch: cpv gesetzt ⇒ { cpvDivisions: { hasSome: cpv } } (D-03, FILTER-03). Ergänze die Builder-Spec (CPV hasSome). Erweitere `FilterPanel.tsx` um (a) ein CPV-Autocomplete-Feld, das gegen den Divisions-Katalog (im Web gespiegelt oder aus geteiltem Modul) sucht und Labels wie „45 – Bauarbeiten“ zeigt, Mehrfachauswahl → URL-Param cpv; und (b) die Wert-min/max-Eingaben plus „ohne Wertangabe einschließen“-Toggle (includeNullValue, Default an) → URL-Params valueMin/valueMax/includeNullValue (D-05, FILTER-05 UI; Builder-Logik existiert bereits aus 11-01). Der Toggle-Default an stellt sicher, dass die 91,6 % NULL-Wert-Zeilen sichtbar bleiben. Strings hardcodiert Deutsch. Ergänzt die FilterPanel aus 11-01/11-02 — bestehende Felder erhalten. + + pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts + + CPV-Autocomplete filtert per Division (matcht gemischte Formate); Wert-min/max + Toggle bedienbar; NULL-Wert-Zeilen bleiben bei aktivem Toggle sichtbar. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → API (CPV/Wert-Filter) | cpv[]/valueMin/valueMax/includeNullValue überschreiten die Grenze zur globalen Tender-Query. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-11-06 | Tampering | CPV-Filter (hasSome) | low | mitigate | Typsicherer Prisma hasSome auf cpvDivisions — kein Raw-SQL/String-Interpolation (V5, Don't Hand-Roll). | +| T-11-07 | Denial of Service | cpv[]-Array-Größe | low | mitigate | class-validator @IsArray; Werte gegen Katalog begrenzt; Pagination-Bounds bleiben aktiv. | +| T-11-SC | Tampering | Paket-Installs | low | accept | Keine neuen npm-Pakete; CPV-Katalog ist repo-committete statische Datei (RESEARCH Package Audit). | + + + +- `pnpm --filter @tessera/api test` grün (cpv-catalog, normalizer, builder). +- DB-Check: cpvDivisions gefüllt; Filter cpv=['45'] liefert Baulose inkl. Zeilen mit '45000000-7'. +- Manuell/UAT: CPV-Autocomplete „Bau“ → Division 45; Wertfilter min setzen bei aktivem Toggle → Liste behält „keine Wertangabe“-Zeilen. + + + +FILTER-03 (CPV hierarchisch + Autocomplete, format-robust) und FILTER-05 (Wert min/max UI, NULL-graceful) end-to-end; ROADMAP-Erfolgskriterium 1 vollständig abgedeckt. + + + +Create `.planning/phases/11-filter-engine-results-ui-saved-searches/11-03-SUMMARY.md` when done + diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-04-PLAN.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-04-PLAN.md new file mode 100644 index 0000000..649026c --- /dev/null +++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-04-PLAN.md @@ -0,0 +1,101 @@ +--- +phase: 11-filter-engine-results-ui-saved-searches +plan: 04 +type: execute +wave: 4 +depends_on: ["11-01"] +files_modified: + - apps/web/src/lib/tender-radar-api.ts + - apps/web/src/app/(portal)/modules/tender-radar/page.tsx + - apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx + - apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx +autonomous: true +requirements: [UI-02] + +must_haves: + truths: + - "Nutzer öffnet aus der Liste eine Detailansicht einer Ausschreibung über ?tender=." + - "Die Detailansicht zeigt alle vorhandenen Felder und einen Link zur Quelle (sourceUrl)." + - "NULL-Felder (Wert, Frist) werden graceful dargestellt; keine Vergabeunterlagen werden lokal gespiegelt." + artifacts: + - apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx + key_links: + - "ResultsList-Zeile setzt ?tender= → page.tsx liest useSearchParams → getTender(id) → GET /modules/tender-radar/:id → TenderDetail." +--- + + +Fügt die Detailansicht pro Ausschreibung hinzu — als In-Component-View über `?tender=` innerhalb der Modulseite (NICHT als Next.js-Unterroute, da das Modul als eine dynamic(ssr:false)-Komponente unter [category]/[moduleSlug] gemountet ist — Anti-Pattern/Assumption A5). Zeigt alle Felder + Quell-Link; keine lokale Spiegelung der Vergabeunterlagen. + +Purpose: UI-02 / ROADMAP-Erfolgskriterium 2. `rawPayload` ist zu 100 % NULL — es existieren keine gespeicherten Dokument-URLs; die Detailansicht verlinkt daher nur auf die Quelle (D-07, Research OQ1, „keep it simple“). +Output: TenderDetail-Komponente + ?tender-Verdrahtung in page.tsx + getTender im API-Client. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/11-filter-engine-results-ui-saved-searches/11-CONTEXT.md +@.planning/phases/11-filter-engine-results-ui-saved-searches/11-RESEARCH.md +@apps/api/src/tenders/tenders.controller.ts +@apps/web/src/lib/tender-radar-api.ts +@apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.test.tsx + + + + + + Task 1: TenderDetail-Komponente + ?tender-Verdrahtung + getTender-Client + apps/web/src/lib/tender-radar-api.ts, apps/web/src/app/(portal)/modules/tender-radar/page.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx + Ergänze in `tender-radar-api.ts` getTender(id): Promise (Plain fetch, credentials:'include', GET /modules/tender-radar/:id). Erstelle `TenderDetail.tsx` (Panel/Drawer): rendert alle vorhandenen Tender-Felder (Titel, Auftraggeber, Region/PLZ/Bundesland, CPV, Frist, Wert, Verfahrensart, Status, Veröffentlichungsdatum) mit gracefuler Darstellung fehlender Werte („keine Wertangabe“, „keine Frist angegeben“) und einem klar sichtbaren Link zur Quelle (sourceUrl, target _blank, rel noopener). Deutlicher Hinweis, dass Vergabeunterlagen nicht lokal gespiegelt werden — nur der Quell-Link führt zum Original (D-07, UI-02); KEIN Live-Dokument-Fetch, KEIN Mirroring (rawPayload 100 % NULL, keine Dokument-URLs verfügbar). Verdrahte in `page.tsx`: eine Listenzeile setzt ?tender= in die URL; die Seite liest useSearchParams().get('tender'), lädt bei gesetztem Wert getTender und rendert TenderDetail als Overlay/Drawer; Schließen entfernt den Param. Strings hardcodiert Deutsch. Ergänzt page.tsx aus 11-01 — bestehende Liste/Filter erhalten. + + pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/TenderDetail.test.tsx + + Klick auf eine Zeile öffnet die Detailansicht via ?tender=; sourceUrl-Link vorhanden; fehlende Felder graceful; kein Dokument-Mirroring. + + + + Task 2: Komponententest TenderDetail + apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx + Vitest + Testing-Library-Test (Muster SourceConfigForm.test.tsx, tender-radar-api gemockt): (a) rendert einen Tender mit sourceUrl → Link vorhanden und zeigt auf sourceUrl; (b) rendert einen Tender mit estimatedValue=null und deadlineAt=null → „keine Wertangabe“ / „keine Frist angegeben“ statt Fehler/leer; (c) enthält den Hinweis, dass keine Vergabeunterlagen gespiegelt werden. Keine der negativ-geprüften Strings verbatim in Kommentaren. + + pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/TenderDetail.test.tsx + + Test grün: Quell-Link, NULL-Graceful-Darstellung und Kein-Mirroring-Hinweis abgedeckt. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → API (Detail-Lookup) | ?tender= → GET /:id auf die globale Tender-Tabelle (ModuleGuard-gated). | +| TenderDetail → externe Quelle | sourceUrl-Link öffnet eine externe Portal-Seite in neuem Tab. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-11-08 | Information Disclosure | GET /:id | low | accept | Tender-Katalog ist global/plattformweit (D-03); nur @UseModule-gated — kein tenant-scoping erforderlich, keine Secrets im Tender (T-10-18). | +| T-11-09 | Tampering | externer sourceUrl-Link | low | mitigate | Link mit rel="noopener noreferrer" + target _blank; keine Ausführung fremder Inhalte, kein Live-Fetch/Mirroring. | +| T-11-SC | Tampering | Paket-Installs | low | accept | Keine neuen npm-Pakete (RESEARCH Package Audit). | + + + +- `pnpm --filter @tessera/web test` grün (TenderDetail.test). +- Manuell/UAT: Zeile anklicken → Detail öffnet; Quell-Link führt zum Portal; Ausschreibung ohne Wert/Frist zeigt graceful Text; kein Dokument-Download in der App. + + + +UI-02 (Detailansicht + Quell-Link, keine lokale Spiegelung) erfüllt; ROADMAP-Erfolgskriterium 2 abgedeckt. + + + +Create `.planning/phases/11-filter-engine-results-ui-saved-searches/11-04-SUMMARY.md` when done + diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-05-PLAN.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-05-PLAN.md new file mode 100644 index 0000000..5a0a15d --- /dev/null +++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-05-PLAN.md @@ -0,0 +1,132 @@ +--- +phase: 11-filter-engine-results-ui-saved-searches +plan: 05 +type: execute +wave: 5 +depends_on: ["11-01"] +files_modified: + - apps/api/prisma/schema.prisma + - apps/api/prisma/migrations/20260721160000_add_tender_triage/migration.sql + - apps/api/src/tenders/tender-triage.service.ts + - apps/api/src/tenders/tender-triage.service.spec.ts + - apps/api/src/tenders/dto/tender-triage.dto.ts + - apps/api/src/tenders/tenders.controller.ts + - apps/api/src/tenders/dto/tender-query.dto.ts + - apps/api/src/tenders/tender-query.builder.ts + - apps/api/src/tenders/tenders.module.ts + - apps/web/src/lib/tender-radar-api.ts + - apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx + - apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx +autonomous: true +requirements: [UI-03, UI-04] + +must_haves: + truths: + - "Nutzer markiert eine Ausschreibung als gelesen/ungelesen — der Zustand ist pro Nutzer, nicht tenant-weit geteilt." + - "Nutzer markiert eine Ausschreibung als Favorit und kann die Liste auf „nur Favoriten“ filtern." + - "Der Triage-Zustand der sichtbaren Zeilen wird per Batch nachgeladen und in die Liste gemerged." + - "Wird ein Tender durch Retention gelöscht, verschwindet die zugehörige Triage-Zeile (Cascade)." + artifacts: + - apps/api/src/tenders/tender-triage.service.ts + - apps/api/prisma/migrations/20260721160000_add_tender_triage/migration.sql + key_links: + - "PUT /modules/tender-radar/triage → upsert on @@unique([userId,tenderId]); GET /triage batch → merge in ResultsList." + - "favOnly → Triage-favIds → where.id in(favIds); statische Routen VOR @Get(':id')." +--- + + +Persönliche Triage: gelesen/ungelesen (UI-03) und Favorit/Merkliste inkl. Merklisten-Filter (UI-04) — pro Nutzer, nach dem belegten FavoritesService-Muster (manuelles where:{userId}-Scoping, KEIN forTenant/RLS — Pitfall 4). Neue Tabelle `TenderTriage` mit Cascade-Delete an Tender. + +Purpose: ROADMAP-Erfolgskriterium 4. Macht die Liste triagierbar, damit Nutzer relevante Vergaben markieren und wiederfinden. +Output: TenderTriage-Modell + Migration, Triage-Service (upsert/batch), Controller-Routen, favOnly im Builder, UI-Toggles + Merklisten-Filter. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/11-filter-engine-results-ui-saved-searches/11-CONTEXT.md +@.planning/phases/11-filter-engine-results-ui-saved-searches/11-RESEARCH.md +@apps/api/src/favorites/favorites.service.ts +@apps/api/src/favorites/favorites.controller.ts +@apps/api/src/tenders/tenders.controller.ts +@apps/api/src/tenders/tenders.module.ts +@apps/api/prisma/schema.prisma + + + + + + Task 1: TenderTriage-Modell + Migration + TenderTriageService (TDD) + apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260721160000_add_tender_triage/migration.sql, apps/api/src/tenders/tender-triage.service.ts, apps/api/src/tenders/tender-triage.service.spec.ts + + - setTriage(userId, tenantId, tenderId, {isRead?, isFavorite?}) ⇒ upsert auf @@unique([userId,tenderId]); zweimaliger Aufruf erzeugt genau eine Zeile (idempotent). + - listForUser(userId, tenderIds[]) ⇒ nur Triage-Zeilen dieses userId (Scoping); Fremd-userId sieht nichts (V4 / IDOR). + - favoriteIds(userId) ⇒ nur tenderIds mit isFavorite=true dieses Nutzers. + - Cascade: Löschen eines Tenders entfernt seine Triage-Zeilen (Pitfall 6). + + Ergänze am Tender-Modell die Back-Relation `triage TenderTriage[]`. Füge `TenderTriage` exakt nach RESEARCH Code Examples hinzu (userId, tenantId, tenderId, isRead, isFavorite, readAt?, favoritedAt?, timestamps, relation zu Tender mit onDelete: Cascade, @@unique([userId,tenderId]), @@index([userId]), @@index([tenderId])). Erstelle die Migration `20260721160000_add_tender_triage/migration.sql` (Tabelle + FK ON DELETE CASCADE + Indizes). Erstelle `TenderTriageService` nach dem FavoritesService-Muster (D-09, D-10, D-11): jede Query where:{userId}; setTriage per prisma.tenderTriage.upsert (where userId_tenderId, partielles update isRead/isFavorite mit readAt/favoritedAt-Setzung), listForUser(userId, tenderIds) und favoriteIds(userId). KEIN forTenant/RLS (Pitfall 4). Zuerst Spec (RED): Idempotenz, userId-Scoping (Fremd-User sieht nichts), Cascade — dann Implementierung (GREEN). Migration lokal anwenden + prisma generate (MEMORY-Hinweis, kein Docker-Deploy Testserver). + + pnpm --filter @tessera/api exec vitest run src/tenders/tender-triage.service.spec.ts + + Upsert idempotent; Scoping verhindert Fremdzugriff; Cascade greift; Migration angewendet. + + + + Task 2: Controller-Routen (GET/PUT /triage) + favOnly im Builder + Modul-Provider + apps/api/src/tenders/dto/tender-triage.dto.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/tender-query.dto.ts, apps/api/src/tenders/tender-query.builder.ts, apps/api/src/tenders/tender-query.builder.spec.ts + Erstelle `dto/tender-triage.dto.ts` (tenderId @IsString/@IsUUID, isRead? @IsBoolean, isFavorite? @IsBoolean). Registriere TenderTriageService als Provider in `tenders.module.ts`. Ergänze in `tenders.controller.ts` die statischen Routen `GET /modules/tender-radar/triage?ids=` (Batch-Triage der sichtbaren IDs für den aktuellen Nutzer) und `PUT /modules/tender-radar/triage` (Body = TriageDto, upsert) — userId/tenantId aus dem Request-Kontext extrahieren wie FavoritesController.extractContext. KRITISCH (Pitfall 5): beide Routen VOR `@Get(':id')` deklarieren. Erweitere `TenderQueryDto` um favOnly (@IsBoolean) und buildTenderWhere so, dass der favOnly-Branch eine übergebene favIds-Liste als { id: { in: favIds.length ? favIds : ['__none__'] } } anhängt (RESEARCH Pattern 5, UI-04) — favIds ermittelt der Controller via triageService.favoriteIds(userId) vor dem buildTenderWhere-Aufruf und reicht sie in den Builder. Ergänze die Builder-Spec um den favOnly-Fall (inkl. leere favIds ⇒ keine Treffer statt aller). Begrenze die in-Liste (DoS). + + pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts src/tenders/tenders.controller.spec.ts + + GET/PUT /triage nicht von :id beschattet; favOnly filtert auf Favoriten des Nutzers; leere Favoriten ⇒ leere Liste, kein Alles-Match. + + + + Task 3: Frontend — Read/Fav-Toggles + Merklisten-Filter + Batch-Merge + apps/web/src/lib/tender-radar-api.ts, apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx + Erweitere `tender-radar-api.ts` um fetchTriage(ids: string[]) (GET /triage) und setTriage(payload) (PUT /triage), Plain fetch. In `ResultsList.tsx`: nach dem Laden der items die Triage der sichtbaren IDs per fetchTriage batchen und mergen; pro Zeile ein Gelesen/Ungelesen-Toggle (D-09, UI-03) und ein Favorit-Toggle (D-10, UI-04), die setTriage aufrufen und den lokalen Zustand optimistisch aktualisieren; gelesene Zeilen visuell dezenter. In `FilterPanel.tsx`: ein „nur Favoriten/Merkliste“-Toggle, das den URL-Param favOnly setzt (UI-04). Strings hardcodiert Deutsch. Ergänzt die Komponenten aus 11-01/02/03 — bestehende Inhalte erhalten. Keine der negativ-geprüften Strings verbatim in Kommentaren. + + pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/ResultsList.test.tsx + + Gelesen/Favorit-Toggles wirken und persistieren pro Nutzer; „nur Favoriten“ filtert die Liste; Triage-Zustand erscheint gemerged. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → API (Triage) | Nutzer setzt/liest eigenen Triage-Zustand; userId aus JWT-Kontext, nicht aus dem Body. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-11-10 | Elevation of Privilege / Info Disclosure | GET/PUT /triage (IDOR) | high | mitigate | Jede Query where:{userId} aus dem Request-Kontext; niemals userId aus dem Client-Body; per-user-Scoping strenger als tenant-RLS (V4, FavoritesService-Muster). | +| T-11-11 | Denial of Service | favOnly in-Liste / triage-ids | medium | mitigate | ids-Batch und favIds-in-Liste begrenzen; Pagination-Bounds aktiv (T-10-15). | +| T-11-12 | Information Disclosure | Cross-tenant Leak neue Tabelle | medium | mitigate | tenantId + userId beim Anlegen setzen; per-user-Scoping isoliert bereits, tenantId für spätere Tenant-Isolation mitgeführt. | +| T-11-13 | Tampering | Route-Order 404-Shadowing | medium | mitigate | Statische Routen triage VOR @Get(':id') (Pitfall 5). | +| T-11-SC | Tampering | Paket-Installs | low | accept | Keine neuen npm-Pakete (RESEARCH Package Audit). | + + + +- `pnpm --filter @tessera/api test` grün (triage-service, builder, controller). +- `pnpm --filter @tessera/web test` grün (ResultsList). +- Manuell/UAT: Zeile als gelesen markieren → bleibt gelesen nach Reload; Favorit setzen; „nur Favoriten“ zeigt nur markierte; zweiter Testnutzer sieht die Markierungen NICHT. + + + +UI-03 (gelesen/ungelesen per-user) und UI-04 (Favorit/Merkliste + Merklisten-Filter per-user) end-to-end; ROADMAP-Erfolgskriterium 4 abgedeckt; D-11 (per-user, nicht tenant-weit) eingehalten. + + + +Create `.planning/phases/11-filter-engine-results-ui-saved-searches/11-05-SUMMARY.md` when done + diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-06-PLAN.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-06-PLAN.md new file mode 100644 index 0000000..b87ba0c --- /dev/null +++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-06-PLAN.md @@ -0,0 +1,128 @@ +--- +phase: 11-filter-engine-results-ui-saved-searches +plan: 06 +type: execute +wave: 6 +depends_on: ["11-01"] +files_modified: + - apps/api/prisma/schema.prisma + - apps/api/prisma/migrations/20260721170000_add_tender_saved_search/migration.sql + - apps/api/src/tenders/tender-saved-search.service.ts + - apps/api/src/tenders/tender-saved-search.service.spec.ts + - apps/api/src/tenders/dto/saved-search.dto.ts + - apps/api/src/tenders/tenders.controller.ts + - apps/api/src/tenders/tenders.module.ts + - apps/web/src/lib/tender-radar-api.ts + - apps/web/src/app/(portal)/modules/tender-radar/page.tsx + - apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx +autonomous: true +requirements: [FILTER-06] + +must_haves: + truths: + - "Nutzer speichert die aktuelle Filterkombination unter einem Namen als persönliches Suchprofil." + - "Nutzer lädt ein Profil (setzt die Filter-URL) und kann es umbenennen/löschen." + - "Profile sind pro Nutzer und auf den eigenen Mandanten gescoped — kein zweiter Nutzer sieht sie." + - "Zwei Profile gleichen Namens pro Nutzer sind ausgeschlossen (@@unique[userId,name])." + artifacts: + - apps/api/src/tenders/tender-saved-search.service.ts + - apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx + key_links: + - "URL-searchParams ↔ filters Json (deckungsgleiche Serialisierung) → POST/PATCH/DELETE /saved-searches; statische Routen VOR @Get(':id')." +--- + + +Persönliche Suchprofile: benannte Filterkombinationen speichern, laden, bearbeiten, löschen — pro Nutzer, auf den eigenen Mandanten gescoped (D-08, D-11, FILTER-06). Da der Filter-State bereits in der URL lebt (11-01..03), ist die serialisierte Filter-Kombination deckungsgleich mit dem `filters Json` des Profils. + +Purpose: ROADMAP-Erfolgskriterium 3. Macht wiederkehrende Filter wiederverwendbar und legt die Grundlage für die Digest/Instant-Benachrichtigungen in Phase 12. +Output: TenderSavedSearch-Modell + Migration, CRUD-Service (userId-scoped), Controller-Routen, SavedSearchBar-UI. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/11-filter-engine-results-ui-saved-searches/11-CONTEXT.md +@.planning/phases/11-filter-engine-results-ui-saved-searches/11-RESEARCH.md +@apps/api/src/favorites/favorites.service.ts +@apps/api/src/favorites/favorites.controller.ts +@apps/api/src/tenders/tenders.controller.ts +@apps/api/src/tenders/tenders.module.ts +@apps/api/prisma/schema.prisma + + + + + + Task 1: TenderSavedSearch-Modell + Migration + CRUD-Service (TDD) + apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260721170000_add_tender_saved_search/migration.sql, apps/api/src/tenders/tender-saved-search.service.ts, apps/api/src/tenders/tender-saved-search.service.spec.ts + + - create(userId, tenantId, {name, filters}) ⇒ Zeile mit userId/tenantId; zweites Profil gleichen Namens desselben Nutzers ⇒ Fehler (@@unique[userId,name]). + - list(userId) ⇒ nur Profile dieses userId (Scoping); Fremd-userId sieht nichts (V4 / IDOR). + - update/remove ⇒ Ownership-Prüfung (userId) vor Mutation, sonst NotFound (FavoritesService-Muster). + + Füge `TenderSavedSearch` exakt nach RESEARCH Code Examples hinzu (id, userId, tenantId, name, filters Json, timestamps, @@unique([userId,name]), @@index([userId]); KEIN Tender-FK — Profil speichert nur Kriterien, unkritisch bei Retention, Pitfall 6). Erstelle die Migration `20260721170000_add_tender_saved_search/migration.sql` (Tabelle + Unique + Index). Erstelle `TenderSavedSearchService` nach FavoritesService-Muster (D-08, D-11): create/list/update/remove, jede Query where:{userId}, Ownership vor update/remove prüfen (NotFound bei Fremdbesitz), KEIN forTenant/RLS (Pitfall 4). Zuerst Spec (RED): unique-Konflikt, userId-Scoping (Fremd-User sieht nichts), Ownership bei update/remove — dann Implementierung (GREEN). Migration lokal anwenden + prisma generate (MEMORY-Hinweis, kein Docker-Deploy Testserver). + + pnpm --filter @tessera/api exec vitest run src/tenders/tender-saved-search.service.spec.ts + + CRUD scoped auf userId; Unique-Constraint greift; Ownership-Prüfung verhindert Fremdmutation; Migration angewendet. + + + + Task 2: Controller-Routen (GET/POST/PATCH/DELETE /saved-searches) + Provider + apps/api/src/tenders/dto/saved-search.dto.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.module.ts + Erstelle `dto/saved-search.dto.ts`: CreateSavedSearchDto (name @IsString @IsNotEmpty @MaxLength, filters @IsObject/als Json validiert) und UpdateSavedSearchDto (name?/filters? optional). Registriere TenderSavedSearchService als Provider in `tenders.module.ts`. Ergänze in `tenders.controller.ts` die statischen Routen `GET /modules/tender-radar/saved-searches` (Liste des Nutzers), `POST /saved-searches` (create), `PATCH /saved-searches/:searchId` (update), `DELETE /saved-searches/:searchId` (remove) — userId/tenantId aus dem Request-Kontext extrahieren wie FavoritesController.extractContext. KRITISCH (Pitfall 5): alle statischen `saved-searches`-Routen VOR `@Get(':id')` deklarieren, damit „saved-searches“ nicht als :id interpretiert wird (nutze einen Sub-Param-Namen wie :searchId für die Mutations-Routen, um Verwechslung mit dem Tender-:id zu vermeiden). + + pnpm --filter @tessera/api exec vitest run src/tenders/tenders.controller.spec.ts + + Alle saved-searches-Routen erreichbar und nicht von :id beschattet; CRUD end-to-end über die API. + + + + Task 3: Frontend — SavedSearchBar (speichern/laden/umbenennen/löschen) + apps/web/src/lib/tender-radar-api.ts, apps/web/src/app/(portal)/modules/tender-radar/page.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx + Erweitere `tender-radar-api.ts` um listSavedSearches(), createSavedSearch(name, filters), updateSavedSearch(id, patch), deleteSavedSearch(id) (Plain fetch). Erstelle `SavedSearchBar.tsx`: zeigt die Profile des Nutzers; „aktuelle Filter speichern“ serialisiert die aktuellen URL-searchParams zu filters Json und ruft createSavedSearch; Auswahl eines Profils schreibt dessen filters zurück in die URL (useRouter().replace) und lädt damit die Liste neu; Umbenennen (PATCH) und Löschen (DELETE) je Profil (D-08, FILTER-06). Serialisierung MUSS deckungsgleich mit den URL-Param-Namen aus 11-01..05 sein (q, plz, bundesland, region, cpv, deadlineFrom/To, openOnly, valueMin/Max, includeNullValue, sort, favOnly). Binde SavedSearchBar in `page.tsx` ein (oberhalb/neben FilterPanel). Strings hardcodiert Deutsch. Ergänzt page.tsx aus 11-01 — bestehende Inhalte erhalten. + + pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar + + Profil speichern/laden/umbenennen/löschen funktioniert; Laden setzt die Filter-URL korrekt; Profile sind pro Nutzer sichtbar. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → API (Saved-Search) | Nutzer speichert/liest/ändert eigene Profile; userId aus JWT-Kontext, nicht aus dem Body. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-11-14 | Elevation of Privilege / Info Disclosure | saved-searches CRUD (IDOR) | high | mitigate | Jede Query where:{userId}; Ownership vor update/remove; niemals userId aus dem Client-Body (V4, FavoritesService-Muster). | +| T-11-15 | Information Disclosure | Cross-tenant Leak neue Tabelle | medium | mitigate | tenantId + userId beim Anlegen setzen; per-user-Scoping isoliert; tenantId für spätere Tenant-Isolation mitgeführt (D-11). | +| T-11-16 | Tampering | Route-Order 404-Shadowing | medium | mitigate | Statische saved-searches-Routen VOR @Get(':id'); Mutations-Param :searchId statt :id (Pitfall 5). | +| T-11-17 | Input Validation | filters Json Payload | low | mitigate | class-validator (name @MaxLength, filters @IsObject); Prisma speichert als parametrisiertes JSON, keine Interpolation (V5). | +| T-11-SC | Tampering | Paket-Installs | low | accept | Keine neuen npm-Pakete (RESEARCH Package Audit). | + + + +- `pnpm --filter @tessera/api test` grün (saved-search-service, controller). +- `pnpm --filter @tessera/web test` grün. +- Manuell/UAT: Filter setzen → als Profil speichern → Seite neu laden → Profil laden setzt Filter; umbenennen/löschen wirkt; zweiter Nutzer sieht die Profile NICHT; gleicher Name zweimal wird abgelehnt. + + + +FILTER-06 (Suchprofile speichern/bearbeiten/löschen, per-user, mandantenbewusst) end-to-end; ROADMAP-Erfolgskriterium 3 abgedeckt. + + + +Create `.planning/phases/11-filter-engine-results-ui-saved-searches/11-06-SUMMARY.md` when done +