From d7b8e9bf5fe5c3d3a4a3b3bc1642f09e98c8d458 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 15:26:18 +0200 Subject: [PATCH] =?UTF-8?q?docs(11):=20revise=20phase=20plan=20per=20check?= =?UTF-8?q?er=20BLOCK=20=E2=80=94=20VALIDATION.md,=20FILTER-04=20date=20ra?= =?UTF-8?q?nge,=20serialized=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 11-VALIDATION.md (Nyquist Dimension 8: per-task test map, sampling, Wave-0 gaps) - Implement FILTER-04 deadlineFrom/deadlineTo (DTO + builder branch + FilterPanel) in 11-01 - Serialize depends_on into a linear chain (11-04<-03, 11-05<-04, 11-06<-05) to prevent parallel shared-file/migration corruption - Mark 11-RESEARCH Open Questions RESOLVED - Fix 11-05 Task 2 dto/ path typo Co-Authored-By: Claude Opus 4.8 (1M context) --- .../11-01-PLAN.md | 6 +- .../11-04-PLAN.md | 2 +- .../11-05-PLAN.md | 4 +- .../11-06-PLAN.md | 2 +- .../11-RESEARCH.md | 10 +- .../11-VALIDATION.md | 101 ++++++++++++++++++ 6 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 .planning/phases/11-filter-engine-results-ui-saved-searches/11-VALIDATION.md 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 index 67e39aa..84a4ede 100644 --- 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 @@ -24,6 +24,7 @@ must_haves: - "Ö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." + - "Nutzer kann die Abgabefrist auf einen Datumsbereich (von/bis) einschränken." - "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: @@ -71,11 +72,12 @@ Output: `tender-query.builder.ts` (where + orderBy), erweitertes `TenderQueryDto 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). + - deadlineFrom/deadlineTo gesetzt ⇒ AND enthält { deadlineAt: { gte: from, lte: to } } (expliziter Frist-Datumsbereich, D-04, FILTER-04) — kombinierbar mit dem openOnly-Toggle. - 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. + 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), deadlineFrom/deadlineTo (@IsOptional @Type(() => Date) @IsDate — ISO-Datumsbereich der Abgabefrist, FILTER-04), 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; für FILTER-04 sowohl der openOnly-Default-Branch ALS AUCH der explizite deadlineFrom/deadlineTo-Range-Branch { deadlineAt: { gte, lte } }) und buildOrderBy(sort): Prisma.TenderOrderByWithRelationInput (SORT_MAP-Whitelist nach Pattern 4). Schreibe zuerst die Spec (RED) inkl. deadlineFrom/deadlineTo-Range, 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 @@ -100,7 +102,7 @@ Output: `tender-query.builder.ts` (where + orderBy), erweitertes `TenderQueryDto - 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. + 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) + zwei Frist-Datumsfelder „Abgabefrist von/bis“ (schreiben deadlineFrom/deadlineTo als ISO in die URL-Params, FILTER-04) — die URL-Param-Namen deadlineFrom/deadlineTo MÜSSEN mit dem Saved-Search-Serialisierungsvertrag aus 11-06 deckungsgleich sein. 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 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 index 649026c..9e5694a 100644 --- 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 @@ -3,7 +3,7 @@ phase: 11-filter-engine-results-ui-saved-searches plan: 04 type: execute wave: 4 -depends_on: ["11-01"] +depends_on: ["11-03"] files_modified: - apps/web/src/lib/tender-radar-api.ts - apps/web/src/app/(portal)/modules/tender-radar/page.tsx 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 index 5a0a15d..4a6c52c 100644 --- 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 @@ -3,7 +3,7 @@ phase: 11-filter-engine-results-ui-saved-searches plan: 05 type: execute wave: 5 -depends_on: ["11-01"] +depends_on: ["11-04"] files_modified: - apps/api/prisma/schema.prisma - apps/api/prisma/migrations/20260721160000_add_tender_triage/migration.sql @@ -79,7 +79,7 @@ Output: TenderTriage-Modell + Migration, Triage-Service (upsert/batch), Controll 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 + 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/dto/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 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 index b87ba0c..6e3be15 100644 --- 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 @@ -3,7 +3,7 @@ phase: 11-filter-engine-results-ui-saved-searches plan: 06 type: execute wave: 6 -depends_on: ["11-01"] +depends_on: ["11-05"] files_modified: - apps/api/prisma/schema.prisma - apps/api/prisma/migrations/20260721170000_add_tender_saved_search/migration.sql diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-RESEARCH.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-RESEARCH.md index 2804653..e296bd4 100644 --- a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-RESEARCH.md +++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-RESEARCH.md @@ -451,21 +451,21 @@ export async function listTenders(params: URLSearchParams) { | A4 | Hardcodierte deutsche UI-Strings sind für Phase 11 akzeptabel (i18n = Phase 14) | Open Question 3 | Mehr Nacharbeit in Phase 14, falls jetzt hardcodiert — geringes Risiko, konsistent mit Stub-Konvention. | | A5 | Detail via In-Component-View/`?tender=` statt Next.js-Unterroute | Anti-Patterns | Falls dedizierte Route gewünscht, muss die dual-routing-Fläche (statisch vs. dynamic-loader) geklärt werden. | -## Open Questions +## Open Questions (RESOLVED) 1. **UI-02 Dokument-URLs, wo `rawPayload` = 100 % NULL** - Was wir wissen: `sourceUrl` (OCDS-Notice-API, `oeffentlichevergabe.de/api/notices/{id}?format=ocds`) und `ocid` sind zu 100 % vorhanden; die eigentlichen Vergabeunterlagen-Links stehen nur im Notice, der nicht persistiert wurde. - Was unklar ist: Ob UI-02 „Dokument-URLs" zwingend erfordert oder ob der Quell-Link genügt. - - Empfehlung: MVP zeigt `sourceUrl` + einen menschenlesbaren Portal-Link; Dokument-URLs optional per **Live-Fetch beim Öffnen des Details** (D-07-konform: nur Links, keine Spiegelung). Alternativ ab jetzt `rawPayload`/Dokument-URLs beim Ingest persistieren (Scope-Zuwachs, ggf. Phase 12+). + - **RESOLVED:** Detailansicht zeigt nur `sourceUrl` (Link zur Quelle), KEIN Live-Dokument-Fetch, KEINE lokale Spiegelung. Umgesetzt in Plan 11-04 (UI-02, D-07). 2. **CPV: Kurzkatalog vs. Vollkatalog** - - Empfehlung: MVP mit Divisions/Gruppen-Katalog (DE-Labels) starten; Vollkatalog (offizielle EU-CPV-Liste als statisches JSON) nur wenn Nutzer feingranulares Autocomplete brauchen. + - **RESOLVED:** Leichtgewichtiger statischer CPV-Divisions-Katalog (2-stellig, DE-Labels) mit beidseitiger Präfix-Normalisierung; KEIN EU-Vollkatalog. Umgesetzt in Plan 11-03 (FILTER-03). 3. **i18n jetzt oder Phase 14** - - `next-intl` ist verdrahtet; der tender-radar-Stub hat i18n bewusst vertagt. Entscheidung: hardcodiertes Deutsch (Stub-Konsistenz, weniger jetzt) vs. `tenderRadar`-Message-Keys jetzt (weniger Nacharbeit Phase 14). + - **RESOLVED:** Hardcodiertes Deutsch nach Stub-Konvention; KEINE next-intl-Keys in dieser Phase — i18n-Rollout ist Phase 14. Gilt für alle Frontend-Tasks in 11-01..06. 4. **TanStack Query einführen?** - - Nicht installiert. Empfehlung: NICHT einführen — Plain-fetch-Muster reicht für Listen/Detail. Nur erwägen, wenn Background-Refresh/optimistische Triage-Updates explizit gewünscht sind. + - **RESOLVED:** NICHT einführen (nicht installiert). Plain `fetch` über das bestehende `tender-radar-api.ts`-Muster. Gilt für alle Client-Aufrufe in 11-01..06. ## Environment Availability diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-VALIDATION.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-VALIDATION.md new file mode 100644 index 0000000..3233e60 --- /dev/null +++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-VALIDATION.md @@ -0,0 +1,101 @@ +--- +phase: 11 +slug: filter-engine-results-ui-saved-searches +status: draft +nyquist_compliant: true +wave_0_complete: false +created: 2026-07-21 +--- + +# Phase 11 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. +> Extracted from 11-RESEARCH.md § Validation Architecture (Test framework, Requirements→Test map, Sampling rate, Wave-0 gaps). + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | Vitest (API 3.x, Web 4.x — both existing, no install) | +| **Config file** | `apps/api/vitest.config.ts` + `apps/web` `test: "vitest run"` (existing — not modified by this phase) | +| **Quick run command (API slice)** | `pnpm --filter @tessera/api exec vitest run src/tenders` | +| **Quick run command (Web slice)** | `pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar` | +| **Full suite command** | `pnpm -r test` | +| **Estimated runtime** | ~10-15 seconds (scoped) | + +--- + +## Sampling Rate + +- **After every task commit:** Run the affected slice spec (`vitest run `). +- **After every plan wave:** Run `pnpm --filter @tessera/api test && pnpm --filter @tessera/web test`. +- **Before `/gsd-verify-work`:** `pnpm -r test` must be green. +- **Max feedback latency:** ~15 seconds (scoped run). + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| +| 11-01-01 | 01 | 1 | FILTER-01/04/05, UI-01 | T-11-01/02/03 | where-Builder: NULL-Wert graceful, Frist-Range + openOnly, Sort-Whitelist | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts` | ❌ W0 | ⬜ pending | +| 11-01-02 | 01 | 1 | UI-01/05 | T-11-01/02 | listTenders nutzt Builder; GET /coverage VOR :id (Route-Order) | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/tenders.controller.spec.ts` | ⚠️ extend | ⬜ pending | +| 11-01-03 | 01 | 1 | FILTER-01, UI-01/05 | — | ResultsList render + „keine Wertangabe" + Coverage-Banner | component | `pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/ResultsList.test.tsx` | ❌ W0 | ⬜ pending | +| 11-02-01 | 02 | 2 | FILTER-02 | T-11-04 | NUTS-1→Bundesland-Map, null-region-safe, Reverse-Präfix | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/geo/nuts-bundesland.spec.ts` | ❌ W0 | ⬜ pending | +| 11-02-02 | 02 | 2 | FILTER-02 | T-11-05 | Normalizer setzt bundesland; Backfill-Migration füllt Bestand | integration | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-normalizer.service.spec.ts`; `prisma migrate status` | ⚠️ extend | ⬜ pending | +| 11-02-03 | 02 | 2 | FILTER-02 | T-11-04 | plz/region/bundesland-Branch + FilterPanel-Dropdown | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts` | ⚠️ extend | ⬜ pending | +| 11-03-01 | 03 | 3 | FILTER-03 | T-11-06 | normalizeCpv über "45"/"45000000"/"45000000-7"; Divisions-Labels | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/cpv/cpv-catalog.spec.ts` | ❌ W0 | ⬜ pending | +| 11-03-02 | 03 | 3 | FILTER-03 | T-11-06 | cpvDivisions-Spalte + Normalizer + Backfill | integration | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-normalizer.service.spec.ts`; `prisma migrate status` | ⚠️ extend | ⬜ pending | +| 11-03-03 | 03 | 3 | FILTER-03/05 | T-11-06/07 | cpv hasSome-Branch + Wert-UI-Toggle (NULL-graceful) | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts` | ⚠️ extend | ⬜ pending | +| 11-04-01 | 04 | 4 | UI-02 | T-11-08/09 | TenderDetail via ?tender=; sourceUrl-Link, kein Mirroring | component | `pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/TenderDetail.test.tsx` | ❌ W0 | ⬜ pending | +| 11-04-02 | 04 | 4 | UI-02 | T-11-09 | Detail: sourceUrl, NULL-graceful, Kein-Mirroring-Hinweis | component | `pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/TenderDetail.test.tsx` | ❌ W0 | ⬜ pending | +| 11-05-01 | 05 | 5 | UI-03/04 | T-11-10/12 | Triage upsert idempotent, userId-Scoping, Cascade-Delete | integration | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-triage.service.spec.ts`; `prisma migrate status` | ❌ W0 | ⬜ pending | +| 11-05-02 | 05 | 5 | UI-04 | T-11-11/13 | /triage-Routen VOR :id; favOnly-Branch (leer ⇒ leere Liste) | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts src/tenders/tenders.controller.spec.ts` | ⚠️ extend | ⬜ pending | +| 11-05-03 | 05 | 5 | UI-03/04 | T-11-10 | Read/Fav-Toggles + Merklisten-Filter + Batch-Merge | component | `pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/ResultsList.test.tsx` | ⚠️ extend | ⬜ pending | +| 11-06-01 | 06 | 6 | FILTER-06 | T-11-14/15 | SavedSearch CRUD: @@unique[userId,name], userId-Scoping, Ownership | integration | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-saved-search.service.spec.ts`; `prisma migrate status` | ❌ W0 | ⬜ pending | +| 11-06-02 | 06 | 6 | FILTER-06 | T-11-16/17 | /saved-searches-Routen VOR :id (:searchId-Param) | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/tenders.controller.spec.ts` | ⚠️ extend | ⬜ pending | +| 11-06-03 | 06 | 6 | FILTER-06 | T-11-14 | SavedSearchBar speichern/laden/umbenennen/löschen; URL-Roundtrip | component | `pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar` | ❌ W0 | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `apps/api/src/tenders/tender-query.builder.spec.ts` — FILTER-01/04/05 (NULL-Graceful ist der Kern-Test) + Frist-Range + Sort-Whitelist (Plan 11-01 Task 1, RED first) +- [ ] `apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx` — Render + „keine Wertangabe" + Coverage-Banner (Plan 11-01 Task 3) +- [ ] `apps/api/src/tenders/geo/nuts-bundesland.spec.ts` — NUTS-1-Ableitung inkl. NULL-region (Plan 11-02 Task 1, RED first) +- [ ] `apps/api/src/tenders/cpv/cpv-catalog.spec.ts` — Präfix-Normalisierung über inkonsistente Formate (Plan 11-03 Task 1, RED first) +- [ ] `apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx` — sourceUrl, NULL-graceful, kein Mirroring (Plan 11-04) +- [ ] `apps/api/src/tenders/tender-triage.service.spec.ts` — Upsert-Idempotenz, userId-Scoping, Cascade (Plan 11-05 Task 1, RED first) +- [ ] `apps/api/src/tenders/tender-saved-search.service.spec.ts` — @@unique, Scoping, Ownership (Plan 11-06 Task 1, RED first) +- [ ] (extend) `apps/api/src/tenders/tenders.controller.spec.ts` — Sort/Pagination-Bounds, coverage, triage- + saved-searches-Route-Order +- [ ] (extend) `apps/api/src/tenders/tender-normalizer.service.spec.ts` — bundesland + cpvDivisions-Ableitung + +Vitest-Framework existiert bereits (api + web) — keine Framework-Installation nötig. + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| Backfill-Migrationen (bundesland, cpvDivisions) auf DB angewendet | FILTER-02/03 | DB hat keinen Host-Port; lokale Anwendung via `docker exec tessera-ctl-db-1 psql -U tessera -d tessera` (MEMORY-Hinweis) | `SELECT bundesland,count(*) FROM "Tender" GROUP BY bundesland` zeigt gefüllte Länder; cpvDivisions nicht-leer > 0 | +| Neue Tabellen-Migrationen (TenderTriage, TenderSavedSearch) angewendet | UI-03/04, FILTER-06 | Lokale Migration + `prisma generate`; kein Docker-Deploy auf dem Testserver durch Claude | `prisma migrate status` grün nach lokalem Apply | +| Triage/Suchprofile sind per-user isoliert | UI-03/04, FILTER-06, D-11 | Cross-User-Sichtbarkeit ist ein Browser-/Mehrbenutzer-Flow | Zweiter Testnutzer sieht Markierungen + Profile des ersten NICHT | +| Coverage-Banner + dünne-Liste-Wahrnehmung | UI-05 | Visuelle/UX-Beurteilung | Banner erklärt Oberschwelle-DÖE; leere Filterergebnisse wirken nicht wie Defekt | + +--- + +## Validation Sign-Off + +- [x] All tasks have `` verify or a documented Wave 0 / manual-gate dependency +- [x] Sampling continuity: no 3 consecutive tasks without automated verify +- [x] Wave 0 covers all MISSING references (spec scaffolds; RED-first for pure-logic units) +- [x] No watch-mode flags (all use `vitest run`) +- [x] Feedback latency < 15s (scoped runs) +- [x] `nyquist_compliant: true` set in frontmatter + +**Approval:** approved 2026-07-21