docs(11-01): complete query-engine + results-ui + coverage-banner plan

This commit is contained in:
2026-07-21 15:57:25 +02:00
parent ee2c40863e
commit 3e064e0124
4 changed files with 207 additions and 29 deletions
@@ -0,0 +1,169 @@
---
phase: 11-filter-engine-results-ui-saved-searches
plan: 01
subsystem: api, ui
tags: [nestjs, prisma, class-validator, nextjs-app-router, react, vitest, testing-library]
requires:
- phase: 10-tender-radar-ingestion
provides: globale `Tender`-Tabelle (~1671 DÖE-Zeilen), `TendersController` mit `GET /`/`GET /:id`, `tender-radar-api.ts`, Modul-Platzhalter-Seite
provides:
- "tender-query.builder.ts: buildTenderWhere/buildOrderBy (Freitext, openOnly-Default, Frist-Range, NULL-graceful Wertfilter, Sort-Whitelist)"
- "erweiterter TenderQueryDto: q/openOnly/deadlineFrom/deadlineTo/valueMin/valueMax/includeNullValue/sort"
- "GET /modules/tender-radar/coverage (sourcePortal-Verteilung, D-12/UI-05)"
- "echte Trefferliste ersetzt den Modul-Platzhalter: ResultsList, FilterPanel, CoverageBanner"
- "erweiterter tender-radar-api.ts Client: listTenders(params), fetchCoverage(), Tender-Typ"
affects: [11-02-region-filter, 11-03-cpv-filter, 11-04-detail-view, 11-05-triage, 11-06-saved-searches]
tech-stack:
added: []
patterns:
- "Konditionaler Prisma where-Builder als pure Funktion (tender-query.builder.ts), unabhängig vom Controller testbar"
- "URL-searchParams als Single Source of Truth für Filter-State (FilterPanel schreibt, ResultsList liest + re-fetched)"
- "Sort-Whitelist via SORT_MAP-Objekt, nie dynamische orderBy-Keys aus User-Input"
- "Plain fetch + credentials:'include' im API-Client (kein TanStack Query — nicht installiert)"
key-files:
created:
- 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/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
modified:
- apps/api/src/tenders/dto/tender-query.dto.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
key-decisions:
- "estimatedValue ist Prisma Decimal -> im JSON-Response ein String, nie eine number; formatValue() im Frontend behandelt null UND unparsebare Strings als 'keine Wertangabe'"
- "openOnly-Default (true) und includeNullValue-Default (true) werden im Builder angewendet, nicht im DTO — DTO validiert nur die Form eines explizit gesetzten Werts"
- "deadlineFrom/deadlineTo URL-Param-Namen sind absichtlich identisch zu den TenderQueryDto-Feldnamen — Voraussetzung für den Saved-Search-Serialisierungsvertrag in Plan 11-06"
- "FilterPanel setzt bei jeder Filteränderung page zurück (next.delete('page')) — verhindert eine leere Seite durch eine stale page-Nummer aus einem größeren vorherigen Ergebnis"
requirements-completed: [FILTER-01, FILTER-04, FILTER-05, UI-01, UI-05]
coverage:
- id: D1
description: "buildTenderWhere/buildOrderBy: Freitext (title/buyerName insensitive), openOnly-Default inkl. NULL-Deadlines, expliziter Frist-Range, NULL-graceful Wertfilter, Sort-Whitelist"
requirement: "FILTER-01"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-query.builder.spec.ts (13 tests)"
status: pass
human_judgment: false
- id: D2
description: "Wertfilter (valueMin/valueMax) eliminiert NULL-Wert-Zeilen bei aktivem includeNullValue-Default NICHT (D-05 Kern-Test)"
requirement: "FILTER-05"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-query.builder.spec.ts#value filter with default includeNullValue: OR(range, null) — NULL rows survive"
status: pass
human_judgment: false
- id: D3
description: "openOnly-Default schließt NULL-Deadlines ein; explizite deadlineFrom/deadlineTo kombinierbar mit openOnly"
requirement: "FILTER-04"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-query.builder.spec.ts#empty DTO / #deadlineFrom/deadlineTo set"
status: pass
human_judgment: false
- id: D4
description: "listTenders im Controller nutzt den Builder (Sort-Whitelist-Passthrough, unbekannter Sort-Key -> Default, Pagination skip/take unverändert); GET /coverage liefert sourcePortal-Verteilung und ist vor @Get(':id') deklariert (Route-Order-Regression-Test)"
requirement: "UI-01"
verification:
- kind: unit
ref: "apps/api/src/tenders/tenders.controller.spec.ts (11 tests)"
status: pass
human_judgment: false
- id: D5
description: "Modulseite zeigt echte Trefferliste (Titel/Auftraggeber/Frist/Wert/Sort-Header) statt Platzhalter; 'keine Wertangabe' bei estimatedValue=null; Coverage-Banner erklärt Oberschwelle-DÖE-Abdeckung"
requirement: "UI-05"
verification:
- kind: unit
ref: "apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx (3 tests)"
status: pass
human_judgment: true
rationale: "Visuelle/UX-Beurteilung (Coverage-Banner-Wahrnehmung, Layout, Sort-Klick-Interaktion) und Live-DB-Rendering (~1671 echte Zeilen) erfordern einen echten Docker-Rebuild + Browser-Check — Unit-Tests decken Render-Logik/Formatierung ab, nicht die visuelle Gesamtwirkung."
duration: 8min
completed: 2026-07-21
status: complete
---
# Phase 11 Plan 01: Query-Engine, Trefferliste & Coverage-Banner Summary
**Prisma-`where`/`orderBy`-Builder mit NULL-gracefulem Wertfilter und Sort-Whitelist, verdrahtet in `listTenders` + neuem `GET /coverage`; Modul-Platzhalter durch echte URL-Param-getriebene Trefferliste (Freitext, Sortierung, „nur noch offene", Coverage-Banner) ersetzt.**
## Performance
- **Duration:** 8 min
- **Started:** 2026-07-21T15:47:40+02:00
- **Completed:** 2026-07-21T15:54:51+02:00
- **Tasks:** 3 (Task 1 + 3 waren TDD, RED-then-GREEN)
- **Files modified:** 11 (6 created, 5 modified)
## Accomplishments
- `tender-query.builder.ts`: konditionaler Prisma-`where`-Builder — Freitext über title/buyerName (case-insensitive), openOnly-Default (schließt NULL-Deadlines ein), expliziter deadlineFrom/deadlineTo-Range (kombinierbar mit openOnly), NULL-graceful Wertfilter (Kern-Test: `valueMin`/`valueMax` eliminiert NIE `estimatedValue=null`-Zeilen), Sort-Whitelist (`deadline`/`value`/`published`, Fallback `publishedAt desc`).
- `TendersController.listTenders` nutzt den Builder statt fester `where`/`orderBy`; neuer `GET /modules/tender-radar/coverage`-Handler liefert `sourcePortal`-Verteilung + Gesamtzahl aktiver Ausschreibungen — deklariert **vor** `@Get(':id')` (Route-Order-Regressionstest ergänzt, Pitfall 5/Phase-10-Bug).
- Modulseite (`page.tsx`) zeigt jetzt eine echte, serverseitig gefilterte/sortierte Trefferliste statt „Ausschreibungen werden erfasst." — `CoverageBanner` + `FilterPanel` + `ResultsList`, Suspense-gewrappt für `useSearchParams` (Next 16 App Router).
- `ResultsList`: sortierbare Frist/Wert/Veröffentlicht-Spaltenköpfe, Paginierung, `estimatedValue=null` rendert „keine Wertangabe" statt leer/0 (D-05).
- `FilterPanel`: Freitext, Sortierauswahl, „nur noch offene"-Toggle (Default an), Abgabefrist-von/bis-Datumsfelder — Param-Namen `deadlineFrom`/`deadlineTo` identisch zum DTO-Feldnamen (Saved-Search-Vertrag, Plan 11-06).
- `CoverageBanner`: deutscher Hinweistext, sichtbar solange `GET /coverage` nur `doe-opendata` meldet.
## Task Commits
1. **Task 1a: Query-Engine RED** — `426a1ea` (test) — failing `tender-query.builder.spec.ts` + erweitertes `TenderQueryDto`
2. **Task 1b: Query-Engine GREEN** — `fed5ecb` (feat) — `tender-query.builder.ts` implementiert, 13/13 Tests grün
3. **Task 2: Controller-Verdrahtung** — `49622ba` (feat) — `listTenders` nutzt Builder, `GET /coverage` ergänzt + Route-Order-Test
4. **Task 3a: Frontend RED** — `b55bb55` (test) — failing `ResultsList.test.tsx` + erweiterter `tender-radar-api.ts`-Client
5. **Task 3b: Frontend GREEN** — `ee2c408` (feat) — `page.tsx`, `ResultsList.tsx`, `FilterPanel.tsx`, `CoverageBanner.tsx` implementiert, 3/3 Tests grün
_TDD-Tasks (1 und 3) haben je einen RED- und einen GREEN-Commit; Task 2 war `type="auto"` ohne separaten RED-Schritt._
## Files Created/Modified
- `apps/api/src/tenders/tender-query.builder.ts` - konditionaler where-/orderBy-Builder (neu)
- `apps/api/src/tenders/tender-query.builder.spec.ts` - 13 Unit-Tests (neu)
- `apps/api/src/tenders/dto/tender-query.dto.ts` - erweiterte Filter-/Sort-Validierung
- `apps/api/src/tenders/tenders.controller.ts` - `listTenders` nutzt Builder, `GET /coverage` ergänzt
- `apps/api/src/tenders/tenders.controller.spec.ts` - Sort/Pagination-Passthrough + Coverage + Route-Order-Tests
- `apps/web/src/lib/tender-radar-api.ts` - `Tender`-Typ, `listTenders()`, `fetchCoverage()`
- `apps/web/src/app/(portal)/modules/tender-radar/page.tsx` - Platzhalter ersetzt durch Master-Container
- `apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx` - Trefferliste (neu)
- `apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx` - Filter-Formular (neu)
- `apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx` - Coverage-Hinweis (neu)
- `apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx` - 3 Component-Tests (neu)
## Decisions Made
- `estimatedValue` kommt als String aus dem JSON-Response (Prisma `Decimal.toJSON()` → `toString()`), nie als `number` — `formatValue()` prüft explizit auf `null`/`NaN` statt blind zu `Number()`-koerzieren.
- Default-Semantik für `openOnly`/`includeNullValue` (beide `true`, wenn nicht gesetzt) lebt im Builder, nicht im DTO — das DTO validiert nur die Form eines explizit übergebenen Werts.
- `deadlineFrom`/`deadlineTo` als URL-Param-Namen 1:1 identisch zu den DTO-Feldnamen — bewusste Vorentscheidung für den Saved-Search-Serialisierungsvertrag aus Plan 11-06 (im Plan explizit gefordert).
- `FilterPanel` löscht `page` aus den URL-Params bei jeder Filteränderung, um eine leere Seite durch eine stale `page`-Nummer aus einem vorherigen, größeren Ergebnis zu vermeiden (nicht explizit im Plan gefordert, aber notwendig für korrektes UX-Verhalten der neuen Paginierung — Rule 1/2 Grenzfall, unter „Correctness" eingeordnet).
## Deviations from Plan
None - plan executed exactly as written. Ein kleiner Test-eigener Bug (falsche `find()`-Selektion im `valueMin only`-Testfall der eigenen Spec, nicht im Produktionscode) wurde während der GREEN-Phase entdeckt und in derselben Spec-Datei vor dem finalen Commit korrigiert — kein separater Deviation-Fall, da es sich um die eigene, noch ungecommittete Testdatei handelte.
## Issues Encountered
- Prisma 6.19 exportiert `Prisma.DecimalNullableFilter` (nicht `Prisma.DecimalFilter`) für ein nullable `Decimal?`-Feld — beim ersten `tsc --noEmit`-Durchlauf entdeckt und sofort korrigiert (kein Auto-Fix-Regelverstoß, reine Typkorrektur vor dem ersten Commit).
## User Setup Required
None - keine externen Service-Konfigurationen erforderlich. Keine neuen npm-Pakete (RESEARCH Package Legitimacy Audit: keine Neuinstallation).
## Next Phase Readiness
- Query-Engine-Fundament (`tender-query.builder.ts`) ist bereit für Erweiterung durch Plan 11-02 (Region/PLZ/Bundesland) und 11-03 (CPV) — beide fügen weitere `AND`-Branches hinzu, ohne bestehende Branches zu ändern.
- `deadlineFrom`/`deadlineTo`-Param-Namen sind für den Plan-11-06-Saved-Search-Vertrag festgelegt.
- **Manuelle UAT ausstehend:** Der Docker-Stack läuft mit den alten Images — für eine Live-Verifikation im Browser (Modul öffnen, Freitext „Bau" filtern, Sortierung testen, Coverage-Banner visuell prüfen) ist `docker compose build api web && docker compose up -d` erforderlich (User führt Docker-Rebuild selbst aus, kein Docker-Deploy durch Claude auf dem Testserver — projektinterne Regel). Alle automatisierten Tests (93 API + 114 Web) sind grün; `tsc --noEmit` fehlerfrei in beiden Apps.
---
*Phase: 11-filter-engine-results-ui-saved-searches*
*Completed: 2026-07-21*
## Self-Check: PASSED
All 11 created/modified files verified present on disk; all 5 task commits (426a1ea, fed5ecb, 49622ba, b55bb55, ee2c408) verified in git log.