docs(11): revise phase plan per checker BLOCK — VALIDATION.md, FILTER-04 date range, serialized deps

- 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 <files> dto/ path typo

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 15:26:18 +02:00
parent 4036991acd
commit d7b8e9bf5f
6 changed files with 114 additions and 11 deletions
@@ -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
<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).
- 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).
</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>
<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), 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.</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts</automated>
</verify>
@@ -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.
</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>
<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) + 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.</action>
<verify>
<automated>pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/ResultsList.test.tsx</automated>
</verify>
@@ -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
@@ -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 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>
<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/dto/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>
@@ -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
@@ -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=<id>` 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
@@ -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 <spec>`).
- **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=<id>; 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 `<automated>` 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