docs(11): create phase plan — 6 vertical-slice plans (filter engine, results UI, saved searches)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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=<id> + 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
|
||||
|
||||
@@ -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?<filter+sort+page> → 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.
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Query-Engine-Kern — TenderQueryDto erweitern + tender-query.builder.ts (TDD)</name>
|
||||
<files>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</files>
|
||||
<behavior>
|
||||
- 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).
|
||||
</behavior>
|
||||
<action>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.</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts</automated>
|
||||
</verify>
|
||||
<done>Spec grün; NULL-Wert-Zeilen bleiben bei aktivem Wertfilter erhalten; Sort-Whitelist verwirft unbekannte Keys.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Controller verdrahten — listTenders über Builder + GET /coverage</name>
|
||||
<files>apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.controller.spec.ts</files>
|
||||
<action>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.</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/tenders/tenders.controller.spec.ts</automated>
|
||||
</verify>
|
||||
<done>listTenders nutzt den Builder; GET /coverage antwortet mit Quellen-Verteilung und ist NICHT vom :id-Handler beschattet.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Frontend — Trefferliste, Freitext/Sortierung, Coverage-Banner (ersetzt Platzhalter)</name>
|
||||
<files>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</files>
|
||||
<behavior>
|
||||
- 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.
|
||||
</behavior>
|
||||
<action>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.</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/ResultsList.test.tsx</automated>
|
||||
</verify>
|
||||
<done>Modulseite zeigt echte Liste statt „Ausschreibungen werden erfasst.“; Freitext + Sort + openOnly wirken über die URL; null-Wert-Zeilen zeigen „keine Wertangabe“; Coverage-Banner sichtbar.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/11-filter-engine-results-ui-saved-searches/11-01-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -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."
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: NUTS-1 → Bundesland-Map (geo/nuts-bundesland.ts, TDD)</name>
|
||||
<files>apps/api/src/tenders/geo/nuts-bundesland.ts, apps/api/src/tenders/geo/nuts-bundesland.spec.ts</files>
|
||||
<behavior>
|
||||
- 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).
|
||||
</behavior>
|
||||
<action>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.</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/tenders/geo/nuts-bundesland.spec.ts</automated>
|
||||
</verify>
|
||||
<done>Alle 16 Länder + NULL-region getestet grün; Reverse liefert korrektes Präfix.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Normalizer setzt bundesland + Backfill-Migration für Bestand</name>
|
||||
<files>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</files>
|
||||
<action>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.</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/tenders/tender-normalizer.service.spec.ts</automated>
|
||||
</verify>
|
||||
<done>Normalizer setzt bundesland aus region; Migration füllt Bestand (Verifikation: SELECT count(*) WHERE bundesland IS NOT NULL > 0); Index vorhanden.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Region/PLZ/Bundesland-Filter — Builder + DTO + FilterPanel</name>
|
||||
<files>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</files>
|
||||
<action>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.</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts</automated>
|
||||
</verify>
|
||||
<done>Bundesland-Dropdown zeigt 16 Länder; Auswahl filtert die Liste auf echte Treffer; PLZ-Präfixfilter wirkt.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/11-filter-engine-results-ui-saved-searches/11-02-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -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."
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Statischer CPV-Divisions-Katalog + normalizeCpv (TDD)</name>
|
||||
<files>apps/api/src/tenders/cpv/cpv-catalog.ts, apps/api/src/tenders/cpv/cpv-catalog.spec.ts</files>
|
||||
<behavior>
|
||||
- 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.
|
||||
</behavior>
|
||||
<action>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).</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/tenders/cpv/cpv-catalog.spec.ts</automated>
|
||||
</verify>
|
||||
<done>Normalisierung über alle drei Formate grün; Divisions-Labels vorhanden.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: cpvDivisions-Spalte — Normalizer + Backfill-Migration</name>
|
||||
<files>apps/api/src/tenders/tender-normalizer.service.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260721150000_tender_cpv_divisions_backfill/migration.sql</files>
|
||||
<action>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.</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/tenders/tender-normalizer.service.spec.ts</automated>
|
||||
</verify>
|
||||
<done>cpvDivisions in Schema + DB vorhanden; Bestand gefüllt (SELECT: Zeilen mit nicht-leerem cpvDivisions > 0); Normalizer setzt sie für neue Zeilen.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: CPV- + Wert-Filter — Builder, DTO, FilterPanel-Autocomplete</name>
|
||||
<files>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</files>
|
||||
<action>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.</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts</automated>
|
||||
</verify>
|
||||
<done>CPV-Autocomplete filtert per Division (matcht gemischte Formate); Wert-min/max + Toggle bedienbar; NULL-Wert-Zeilen bleiben bei aktivem Toggle sichtbar.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/11-filter-engine-results-ui-saved-searches/11-03-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -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=<id>."
|
||||
- "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=<id> → page.tsx liest useSearchParams → getTender(id) → GET /modules/tender-radar/:id → TenderDetail."
|
||||
---
|
||||
|
||||
<objective>
|
||||
Fügt die Detailansicht pro Ausschreibung hinzu — als In-Component-View über `?tender=<id>` 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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: TenderDetail-Komponente + ?tender-Verdrahtung + getTender-Client</name>
|
||||
<files>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</files>
|
||||
<action>Ergänze in `tender-radar-api.ts` getTender(id): Promise<Tender> (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=<id> 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.</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/TenderDetail.test.tsx</automated>
|
||||
</verify>
|
||||
<done>Klick auf eine Zeile öffnet die Detailansicht via ?tender=<id>; sourceUrl-Link vorhanden; fehlende Felder graceful; kein Dokument-Mirroring.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Komponententest TenderDetail</name>
|
||||
<files>apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx</files>
|
||||
<action>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.</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/TenderDetail.test.tsx</automated>
|
||||
</verify>
|
||||
<done>Test grün: Quell-Link, NULL-Graceful-Darstellung und Kein-Mirroring-Hinweis abgedeckt.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → API (Detail-Lookup) | ?tender=<id> → 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). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
UI-02 (Detailansicht + Quell-Link, keine lokale Spiegelung) erfüllt; ROADMAP-Erfolgskriterium 2 abgedeckt.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/11-filter-engine-results-ui-saved-searches/11-04-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -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')."
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: TenderTriage-Modell + Migration + TenderTriageService (TDD)</name>
|
||||
<files>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</files>
|
||||
<behavior>
|
||||
- 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).
|
||||
</behavior>
|
||||
<action>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).</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/tenders/tender-triage.service.spec.ts</automated>
|
||||
</verify>
|
||||
<done>Upsert idempotent; Scoping verhindert Fremdzugriff; Cascade greift; Migration angewendet.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Controller-Routen (GET/PUT /triage) + favOnly im Builder + Modul-Provider</name>
|
||||
<files>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</files>
|
||||
<action>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=<csv>` (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).</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts src/tenders/tenders.controller.spec.ts</automated>
|
||||
</verify>
|
||||
<done>GET/PUT /triage nicht von :id beschattet; favOnly filtert auf Favoriten des Nutzers; leere Favoriten ⇒ leere Liste, kein Alles-Match.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Frontend — Read/Fav-Toggles + Merklisten-Filter + Batch-Merge</name>
|
||||
<files>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</files>
|
||||
<action>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.</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/ResultsList.test.tsx</automated>
|
||||
</verify>
|
||||
<done>Gelesen/Favorit-Toggles wirken und persistieren pro Nutzer; „nur Favoriten“ filtert die Liste; Triage-Zustand erscheint gemerged.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/11-filter-engine-results-ui-saved-searches/11-05-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -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')."
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: TenderSavedSearch-Modell + Migration + CRUD-Service (TDD)</name>
|
||||
<files>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</files>
|
||||
<behavior>
|
||||
- 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).
|
||||
</behavior>
|
||||
<action>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).</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/tenders/tender-saved-search.service.spec.ts</automated>
|
||||
</verify>
|
||||
<done>CRUD scoped auf userId; Unique-Constraint greift; Ownership-Prüfung verhindert Fremdmutation; Migration angewendet.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Controller-Routen (GET/POST/PATCH/DELETE /saved-searches) + Provider</name>
|
||||
<files>apps/api/src/tenders/dto/saved-search.dto.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.module.ts</files>
|
||||
<action>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).</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/tenders/tenders.controller.spec.ts</automated>
|
||||
</verify>
|
||||
<done>Alle saved-searches-Routen erreichbar und nicht von :id beschattet; CRUD end-to-end über die API.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Frontend — SavedSearchBar (speichern/laden/umbenennen/löschen)</name>
|
||||
<files>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</files>
|
||||
<action>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.</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar</automated>
|
||||
</verify>
|
||||
<done>Profil speichern/laden/umbenennen/löschen funktioniert; Laden setzt die Filter-URL korrekt; Profile sind pro Nutzer sichtbar.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
FILTER-06 (Suchprofile speichern/bearbeiten/löschen, per-user, mandantenbewusst) end-to-end; ROADMAP-Erfolgskriterium 3 abgedeckt.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/11-filter-engine-results-ui-saved-searches/11-06-SUMMARY.md` when done
|
||||
</output>
|
||||
Reference in New Issue
Block a user