From 930a8d8f797712a1a48e6b79051f902d4be4e38d Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 15:07:41 +0200 Subject: [PATCH] =?UTF-8?q?docs(11):=20phase=20research=20=E2=80=94=20live?= =?UTF-8?q?-DB=20findings,=20query=20patterns,=20new=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../11-RESEARCH.md | 567 ++++++++++++++++++ 1 file changed, 567 insertions(+) create mode 100644 .planning/phases/11-filter-engine-results-ui-saved-searches/11-RESEARCH.md 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 new file mode 100644 index 0000000..2804653 --- /dev/null +++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-RESEARCH.md @@ -0,0 +1,567 @@ +# Phase 11: Filter Engine, Results UI & Saved Searches - Research + +**Researched:** 2026-07-21 +**Domain:** Server-side Query/Filter-Engine über eine globale Postgres-Tabelle (Prisma/NestJS) + Next.js-App-Router-Trefferliste mit persönlichen (per-user) Suchprofilen und Triage. +**Confidence:** HIGH (alle Kernbefunde gegen die laufende DB und den Quellcode verifiziert) + +## Summary + +Phase 11 baut die Anzeige- und Interaktionsschicht auf der bereits gefüllten globalen `Tender`-Tabelle (verifiziert: 1671 Zeilen, davon 1639 `active`). Die Arbeit ist **fast vollständig mit dem vorhandenen Stack umsetzbar** — es sind keine neuen Runtime-npm-Pakete nötig. Der Backend-Teil erweitert die bestehenden Read-Endpunkte in `TendersController` um `where`/`orderBy`/Pagination; der Frontend-Teil ersetzt den Platzhalter `modules/tender-radar/page.tsx` durch eine echte Trefferliste, Filter-Panel und Detailansicht. + +Die **Datenrealität diktiert mehrere Design-Entscheidungen** und weicht von naiven Annahmen ab (alle gegen die Live-DB verifiziert): `bundesland` ist zu **100 % NULL** (Normalizer hat NUTS→Bundesland explizit auf diese Phase vertagt), `estimatedValue` ist zu **91,6 % NULL** (Graceful-Handling ist damit kein Randfall, sondern der Normalfall), `rawPayload` ist zu **100 % NULL** (es gibt keine gespeicherten Dokument-URLs für UI-02), und `cpvCodes` liegen in **inkonsistenten Formaten** vor ("45", "45000000", "45000000-7"). `region` enthält NUTS-Codes (DE27B…), aus deren NUTS-1-Präfix sich Bundesland zuverlässig ableiten lässt. + +Der etablierte Codebase-Konvention für **per-user-Modultabellen** ist NICHT `forTenant()`/RLS, sondern manuelles `where: { userId }`-Scoping im Service (siehe `FavoritesService`, T-08-06) — RLS-Policies existieren nur für Auth-Kerntabellen. TanStack Query ist trotz CLAUDE.md-Empfehlung **nicht installiert**; das reale Datenabruf-Muster ist `fetch` + `useState`/`useEffect`. + +**Primary recommendation:** Backend-Filter deklarativ über einen konditionalen Prisma-`where`-Builder (kein Raw-SQL außer optional für CPV-Präfix), neue Modelle `TenderSavedSearch` + `TenderTriage` nach dem `FavoritesService`-Muster (userId-Scoping, kein RLS), Bundesland aus `region`-NUTS-Präfix ableiten (Normalizer + Backfill-Migration), Frontend als URL-Param-getriebene Master-Detail-Ansicht mit Plain-`fetch`-Client — konsistent mit dem bestehenden `tender-radar-api.ts`. + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**Filter (alle in dieser Phase)** +- **D-01:** Freitext-Suche über Titel + Auftraggeber (`buyerName`). FILTER-01. (REQUIREMENTS nennt Titel/Beschreibung — Beschreibung liegt ggf. in `rawPayload`; Freitext auf indexierte Felder Titel+buyerName, `rawPayload`-Volltext ist Claude's Discretion.) +- **D-02:** Region / PLZ / Bundesland. FILTER-02. Felder liegen vor (`region`, `plz`, `bundesland`). +- **D-03:** CPV-Code / Branche — hierarchische Auswahl mit Autocomplete. FILTER-03. `cpvCodes` liegt als Array vor. Hierarchie/Autocomplete-Datenquelle (CPV-Katalog) = Research-Punkt. +- **D-04:** Abgabefrist inkl. Option „nur noch offene" (Standard-Ansicht). FILTER-04. `deadlineAt` liegt vor. +- **D-05:** Geschätzter Auftragswert min/max. FILTER-05. **Ausschreibungen ohne Wertangabe (`estimatedValue` null) dürfen NIE ausgeschlossen oder zu Fehlern führen** — sie erscheinen weiter, nur nicht bei aktivem min/max-Wertfilter, oder mit klarer „keine Wertangabe"-Markierung (genaue Semantik = Plan-Entscheidung, aber „graceful" ist Pflicht-Erfolgskriterium). + +**Trefferliste & Detail** +- **D-06:** Durchsuchbare, sortierbare Liste — Sortierung nach Frist, Wert, Veröffentlichungsdatum. UI-01. +- **D-07:** Detailansicht pro Ausschreibung: alle Felder + Link zur Quelle (`sourceUrl`) + ggf. Dokument-URLs aus dem Notice. **Keine lokale Spiegelung der Vergabeunterlagen** — nur Links. UI-02. + +**Persönliche Funktionen (pro Nutzer, mandantenbewusst)** +- **D-08:** Suchprofile: Filter-Kombination benannt speichern, bearbeiten, löschen. **Pro Nutzer**, auf den eigenen Mandanten gescoped. FILTER-06. +- **D-09:** Gelesen/Ungelesen pro Nutzer. UI-03. +- **D-10:** Favorit/Merkliste pro Nutzer + Merklisten-Filter in der Liste. UI-04. +- **D-11:** Triage-Zustände (gelesen, favorit) und Suchprofile sind **per-user**, NICHT tenant-weit geteilt. + +**Abdeckungs-Transparenz** +- **D-12:** UI weist Datenabdeckung aus: Oberschwelle vs. Unterschwelle. UI-05. Aktuell nur DÖE-Quelle — die Anzeige verhindert, dass eine dünne/leere Liste als Fehler gelesen wird. Genaue Formulierung/Platzierung = Plan-Entscheidung; der Hinweis MUSS aber existieren. + +### Claude's Discretion +- Query-Architektur: serverseitige Filter/Sortierung/Pagination über die globale `Tender`-Tabelle (kein `forTenant`-Scoping — Tender ist global), Prisma `where`-Aufbau, Indizes für Filter-Performance. +- Neue **per-user/per-tenant** Tabellen für Suchprofile + Triage (read/favourite) — Namen, Schema, Unique-Constraints. `FavoriteLink` existiert bereits für ein anderes Feature; NICHT wiederverwenden, eigenes Tender-Triage-Modell. +- CPV-Hierarchie/Autocomplete-Umsetzung (statischer Katalog vs. abgeleitet aus vorhandenen `cpvCodes`). +- Frontend-Aufteilung: Liste, Filter-Panel, Detailseite, Suchprofil-Verwaltung — Komponenten + TanStack-Query-Hooks. + +### Deferred Ideas (OUT OF SCOPE) +- E-Mail-Benachrichtigungen (Phase 12) +- Scraping-Adapter + Cross-Source-Dedup (Phase 13) +- RSS/E-Mail-Quellen + ausgeschlossene-Portale-Transparenz UI-06 + i18n-Rollout (Phase 14) +- CSV/Excel-Export (Backlog) + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| FILTER-01 | Volltext-Stichwort (Titel/Auftraggeber) | `where.OR` mit `title`/`buyerName` `contains … mode:'insensitive'` — bei 1671 Zeilen kein Index nötig (Pattern 1). Beschreibung nicht durchsuchbar: `rawPayload` = 100 % NULL. | +| FILTER-02 | Region / PLZ / Bundesland | `plz` (60 % befüllt) direkt; `region` = NUTS-Code; **`bundesland` 100 % NULL → aus `region`-NUTS-1-Präfix ableiten** (Pattern 2, Pitfall 1). | +| FILTER-03 | CPV hierarchisch + Autocomplete | Statischer CPV-Divisions/Gruppen-Katalog mit DE-Labels + Präfix-Match; `cpvCodes` inkonsistent formatiert → beidseitig normalisieren (Pattern 3, Pitfall 2). | +| FILTER-04 | Abgabefrist + „nur noch offene" (Default) | `deadlineAt` Range; „offen" = `deadlineAt >= now OR deadlineAt IS NULL` — 17 % NULL-Deadlines nicht verstecken (Pattern 4). | +| FILTER-05 | Auftragswert min/max, NULL graceful | 91,6 % NULL → `OR:[{estimatedValue:{gte,lte}},{estimatedValue:null}]` + „ohne Wertangabe einschließen"-Toggle (Default an) (Pattern 5, Pitfall 3). | +| FILTER-06 | Suchprofile speichern/bearbeiten/löschen (per-user) | Neues Modell `TenderSavedSearch` (userId+tenantId, `filters Json`), `@@unique([userId,name])`, `FavoritesService`-Scoping-Muster. | +| UI-01 | Durchsuchbare, sortierbare Liste (Frist/Wert/Datum) | `orderBy` aus whitelisted Sort-Key; Pagination wie bestehend. Indizes vorhanden für `deadlineAt`/`publishedAt`, `estimatedValue` ergänzen. | +| UI-02 | Detailansicht + Quell-Link + Dokument-URLs | `sourceUrl` 100 % befüllt; **Dokument-URLs NICHT gespeichert (`rawPayload` NULL)** → nur `sourceUrl` + `ocid`-Portal-Link; Live-Fetch optional (Open Question 1). | +| UI-03 | Gelesen/Ungelesen (per-user) | `TenderTriage.isRead`, `@@unique([userId,tenderId])`, Upsert; Batch-Merge in Listen-Page. | +| UI-04 | Favorit/Merkliste + Merklisten-Filter (per-user) | `TenderTriage.isFavorite`; Merklisten-Filter treibt Tender-Query aus Triage-`tenderId`-Liste. | +| UI-05 | Abdeckungs-Anzeige (Oberschwelle/Unterschwelle) | Statisches Coverage-Banner, gespeist aus `distinct sourcePortal` (=1: `doe-opendata`); kein Schwellen-Feld in Daten (Pattern 6). | + + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Filter/Sort/Pagination über Tender | API / Backend (NestJS + Prisma) | — | Globale Tabelle, kein Client-seitiges Filtern über 1671+ Zeilen; `where`-Builder gehört in den Controller/Service. | +| Bundesland-Ableitung aus NUTS | API / Backend (Normalizer + Backfill) | Database (Backfill-Migration) | Ableitung muss für neue Ingests im Normalizer greifen + einmalig für Bestand nachgezogen werden. | +| CPV-Katalog + Autocomplete | Frontend Server (Static Data) | API (Filter-Match) | Katalog ist statische Referenzdaten (JSON im Repo); Autocomplete rein clientseitig, Match serverseitig per Präfix. | +| Saved Searches (CRUD) | API / Backend | Database (neue Tabelle) | Per-user Persistenz; userId-Scoping im Service. | +| Triage read/favourite | API / Backend | Database (neue Tabelle) | Per-user Zustand pro Tender; Upsert auf `@@unique([userId,tenderId])`. | +| Filter-State (aktive Filter) | Browser / Client (URL searchParams) | — | Teilbar, deep-linkbar, deckungsgleich mit Saved-Search-Serialisierung. | +| Trefferliste / Detail-Rendering | Browser / Client ('use client') | — | Module werden via dynamic import mit `ssr:false` geladen (module-loader.ts). | +| Coverage-Banner | Browser / Client | API (`distinct sourcePortal`) | Signal kommt vom Backend, Darstellung statisch im Client. | + +## Standard Stack + +### Core (alles bereits im Projekt vorhanden — keine Neuinstallation) +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Prisma | 7.x (Client generiert) | `where`/`orderBy`/`count`/`upsert` Query-Bau | Bereits im `TendersController` genutzt; typsicher [VERIFIED: codebase apps/api/src/tenders/tenders.controller.ts]. | +| NestJS + class-validator | 11.x | Erweitertes `TenderQueryDto` (Filter-Params validieren) | Bestehendes `TenderQueryDto`-Muster mit `@Type`/`@IsOptional` [VERIFIED: codebase dto/tender-query.dto.ts]. | +| Next.js App Router (React 19) | 16.x | Trefferliste, Filter-Panel, Detail — `'use client'` | Module via `dynamic(..., {ssr:false})` in module-loader.ts [VERIFIED: codebase]. | +| next-intl | 4.13.x | i18n — bereits verdrahtet (`useTranslations`) | dkv-fleet nutzt es; tender-radar-Stub hat i18n bewusst nach Phase 14 vertagt (Discretion, s. Open Question 3). | +| Zustand | 5.0.x | optionaler UI-State (Panel offen/zu) | Installiert; für Filter-State jedoch URL-Params bevorzugt. | +| Tailwind + shadcn-artige Komponenten | 4.x | Layout (bestehende Utility-Klassen-Konvention) | SourceConfigForm/dkv-Komponenten als Vorlage [VERIFIED: codebase]. | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| Vitest | 3.x | Unit-Tests Web (`vitest run`) + API-Specs | Web-`test`-Script vorhanden; API hat `.spec.ts` neben Services [VERIFIED: codebase]. | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Plain `fetch` + `useState` (etabliert) | TanStack Query | **NICHT installiert** — Einführung wäre Netto-Neu-Infra für eine Phase; inkonsistent mit `tender-radar-api.ts`/dkv. Nur einführen, wenn Caching/Background-Refresh explizit gewünscht (Open Question 4). | +| `forTenant()`/RLS für neue Tabellen | manuelles `where:{userId}`-Scoping | Kein Modultabellen-Modell nutzt RLS; `FavoritesService` (T-08-06) ist die belegte Konvention. CONTEXT nennt `forTenant`, die Codebase-Realität ist manuelles Scoping (Pitfall 4). | +| CPV-Präfix via Prisma `hasSome` auf normalisierter Spalte | Raw-SQL `unnest … LIKE` | `hasSome` ist typsicher + GIN-indexierbar, erfordert aber eine normalisierte CPV-Spalte (Ingestion/Backfill). Raw-SQL vermeidet Schema-Änderung, mischt aber SQL in den Controller. | + +**Installation:** Keine neuen Runtime-Pakete. (Optionaler CPV-Vollkatalog = statische JSON-Datendatei im Repo, kein npm-Dependency.) + +## Package Legitimacy Audit + +> Diese Phase installiert **keine** externen Pakete — sie nutzt ausschließlich bereits im Monorepo vorhandene Dependencies (Prisma, NestJS, Next.js, next-intl, Zustand, Vitest). + +| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition | +|---------|----------|-----|-----------|-------------|---------|-------------| +| — (keine Neuinstallation) | — | — | — | — | N/A | — | + +**Packages removed due to [SLOP] verdict:** none +**Packages flagged as suspicious [SUS]:** none + +*Falls der Planer sich für einen CPV-Vollkatalog entscheidet: die offizielle EU-CPV-Liste ist ein statischer Datensatz (JSON/CSV), der als Datei ins Repo committet wird — kein npm-Paket, keine Supply-Chain-Fläche.* + +## Architecture Patterns + +### System Architecture Diagram + +``` +[Browser: tender-radar/page.tsx ('use client')] + │ URL searchParams (?q=&plz=&cpv=&open=1&sort=deadline&fav=1&tender=) + │ ─ FilterPanel schreibt Params ─ ResultsList liest Params + ▼ +[tender-radar-api.ts fetch(credentials:'include')] + │ + ├── GET /modules/tender-radar? ──┐ + │ (globaler Katalog, @UseModule-gated) │ + ├── GET /modules/tender-radar/:id (Detail) │ + ├── GET/POST/PATCH/DELETE /modules/tender-radar/ │ + │ saved-searches (per-user) │ + └── GET/PUT /modules/tender-radar/triage │ + (per-user read/favourite) │ + ▼ ▼ +[TendersController → PrismaService] [SavedSearch/Triage Service → where:{userId}] + │ buildTenderWhere(dto): konditionaler │ userId aus @CurrentUser/req.user + │ where-Builder (keyword/plz/region→NUTS/ │ @@unique([userId,name]) / ([userId,tenderId]) + │ cpv-präfix/deadline/value-OR-null) │ onDelete:Cascade an Tender (Retention!) + ▼ ▼ +[Postgres "Tender" (global, 1671 Zeilen, kein tenantId)] [neue Tabellen (per-user)] + Indizes: status, deadlineAt, publishedAt (+ estimatedValue, region/bundesland ergänzen) +``` + +Datenfluss-Kern: Der Filter-State lebt in der URL, wird vom Client in Query-Params übersetzt, serverseitig zu einem Prisma-`where` gebaut. Triage/Saved-Searches laufen über **getrennte, user-gescopte** Endpunkte — die globale Tender-Query bleibt ungescoped. + +### Recommended Project Structure +``` +apps/api/src/tenders/ +├── tenders.controller.ts # ERWEITERN: listTenders(where-Builder), getTender bleibt +├── dto/tender-query.dto.ts # ERWEITERN: q, plz, bundesland, region, cpv[], deadlineFrom/To, openOnly, valueMin/Max, includeNullValue, sort, favOnly +├── dto/saved-search.dto.ts # NEU: create/update Suchprofil +├── tender-saved-search.service.ts # NEU: userId-gescopt (FavoritesService-Muster) +├── tender-triage.service.ts # NEU: upsert read/favourite +├── tender-query.builder.ts # NEU: buildTenderWhere(dto) + buildOrderBy(sort) +├── cpv/cpv-catalog.ts # NEU: statischer Divisions/Gruppen-Katalog (DE-Labels) + normalizeCpv() +└── geo/nuts-bundesland.ts # NEU: NUTS-1-Präfix → Bundesland-Name Map + +apps/web/src/app/(portal)/modules/tender-radar/ +├── page.tsx # ERSETZEN: Platzhalter → Master-Detail-Container +├── components/ +│ ├── ResultsList.tsx # Tabelle/Cards + Sort-Header + Read/Fav-Toggles +│ ├── FilterPanel.tsx # Keyword, PLZ/Bundesland, CPV-Autocomplete, Frist, Wert +│ ├── TenderDetail.tsx # Detail-Panel/Drawer (via ?tender=) +│ ├── SavedSearchBar.tsx # Speichern/Laden/Löschen von Profilen +│ └── CoverageBanner.tsx # UI-05 +apps/web/src/lib/tender-radar-api.ts # ERWEITERN: listTenders, getTender, savedSearches, triage +``` + +### Pattern 1: Konditionaler Prisma `where`-Builder (keyword + insensitive contains) +**What:** Filter-Bedingungen nur anhängen, wenn Param gesetzt — leere Params ⇒ kein Constraint. +**When to use:** `listTenders` in `TendersController`. +```typescript +// buildTenderWhere(dto): Prisma.TenderWhereInput +const where: Prisma.TenderWhereInput = {}; +const AND: Prisma.TenderWhereInput[] = []; + +// status/openOnly (D-04): "nur noch offene" = Standard-Default +where.status = dto.status ?? 'active'; +if (dto.openOnly ?? true) { + AND.push({ OR: [{ deadlineAt: { gte: new Date() } }, { deadlineAt: null }] }); +} + +// FILTER-01: keyword über title + buyerName, case-insensitive +if (dto.q) { + AND.push({ + OR: [ + { title: { contains: dto.q, mode: 'insensitive' } }, + { buyerName: { contains: dto.q, mode: 'insensitive' } }, + ], + }); +} + +// FILTER-02: plz exakt/Präfix; bundesland → NUTS-1-Präfix auf region +if (dto.plz) AND.push({ plz: { startsWith: dto.plz } }); +if (dto.bundesland) AND.push({ region: { startsWith: nutsPrefixFor(dto.bundesland) } }); +// (nach Backfill alternativ: { bundesland: dto.bundesland }) + +if (AND.length) where.AND = AND; +return where; +``` +> Bei ~1671 Zeilen ist `contains`/`ILIKE` ein Full-Scan im Sub-Millisekunden-Bereich — **kein** Trigram/GIN-Index nötig (siehe State of the Art bei Wachstum). + +### Pattern 2: NULL-Werte graceful (FILTER-05, D-05 — Pflichtkriterium) +**What:** Aktiver min/max-Wertfilter darf `estimatedValue = null` (91,6 % der Daten!) nicht stillschweigend eliminieren. +**When to use:** Immer wenn `valueMin`/`valueMax` gesetzt sind. +```typescript +// FILTER-05: 1530/1671 Zeilen haben estimatedValue = NULL. +if (dto.valueMin != null || dto.valueMax != null) { + const range: Prisma.DecimalFilter = {}; + if (dto.valueMin != null) range.gte = dto.valueMin; + if (dto.valueMax != null) range.lte = dto.valueMax; + // includeNullValue Default true: Einträge ohne Wertangabe bleiben sichtbar. + AND.push( + (dto.includeNullValue ?? true) + ? { OR: [{ estimatedValue: range }, { estimatedValue: null }] } + : { estimatedValue: range }, + ); +} +``` +**Kritisch:** Ohne das `OR … { estimatedValue: null }` würde ein Wertfilter 91,6 % der echten Daten verschwinden lassen — genau der „leere Liste wirkt wie Bug"-Effekt, den D-12 adressiert. Das Frontend markiert NULL-Wert-Zeilen mit „keine Wertangabe". + +### Pattern 3: CPV-Präfix-Match über inkonsistente Array-Werte (FILTER-03) +**What:** `cpvCodes` enthalten gemischt "45", "45000000", "45000000-7". CPV ist hierarchisch per Präfix (erste 2 Ziffern = Division). Match beidseitig auf die führenden Ziffern normalisieren. +```typescript +// normalizeCpv('45000000-7') -> '45000000'; führende 2 Ziffern = Division '45' +// Nutzer wählt Division/Gruppe im Katalog → wir matchen Präfix gegen JEDES Array-Element. +// Prisma kann kein per-Element-LIKE auf String[]; zwei tragfähige Wege: + +// (A) MVP ohne Schema-Änderung — Raw-SQL-Fragment für die CPV-Bedingung: +// EXISTS (SELECT 1 FROM unnest("cpvCodes") c WHERE c LIKE '45%') +// via prisma.$queryRaw für die Vorfilter-ID-Liste, dann in where.id in(...) + +// (B) EMPFOHLEN — normalisierte Divisions-Spalte an Tender: +// cpvDivisions String[] (z.B. ['45','71']) @ Ingestion + Backfill +// Filter: { cpvDivisions: { hasSome: selectedDivisions } } // typsicher, GIN-indexierbar +``` +**Katalog:** Statischer Divisions/Gruppen-Katalog (2-stellige Divisionen ≈ 45 Einträge + 3-stellige Gruppen) mit deutschen Labels als JSON im Repo — liefert Hierarchie + menschenlesbares Autocomplete („45 – Bauarbeiten"), ohne die ~9500 EU-CPV-Codes zu bündeln. Vollkatalog optional als Upgrade (Open Question 2). + +### Pattern 4: Sort-Whitelist (UI-01, D-06) +```typescript +// buildOrderBy(sort) — nur erlaubte Keys, verhindert beliebige Feldsortierung +const SORT_MAP = { + deadline: { deadlineAt: 'asc' as const }, // nächste Frist zuerst + value: { estimatedValue: 'desc' as const }, + published: { publishedAt: 'desc' as const }, // Default (wie bisher) +} satisfies Record; +const orderBy = SORT_MAP[dto.sort ?? 'published']; +``` +> Postgres sortiert NULLs bei `asc` per Default zuletzt / bei `desc` zuerst. Bei Sortierung nach Frist/Wert (17 %/92 % NULL) ggf. `{ sort: { nulls: 'last' } }` erwägen — Prisma unterstützt `nulls`-Positionierung. + +### Pattern 5: Per-user Triage via Upsert + Batch-Merge (UI-03/04) +**What:** Read/Favourite-Zustand pro (user, tender). Setzen = Upsert auf `@@unique([userId,tenderId])`; Liste = Triage der sichtbaren IDs in einem Batch nachladen und mergen. +```typescript +// Setzen (idempotent): +prisma.tenderTriage.upsert({ + where: { userId_tenderId: { userId, tenderId } }, + update: { isRead: dto.isRead ?? undefined, isFavorite: dto.isFavorite ?? undefined }, + create: { userId, tenantId, tenderId, isRead: !!dto.isRead, isFavorite: !!dto.isFavorite }, +}); + +// Merklisten-Filter (UI-04): Query aus Triage treiben, dann Tender laden +if (dto.favOnly) { + const favIds = (await prisma.tenderTriage.findMany({ + where: { userId, isFavorite: true }, select: { tenderId: true }, + })).map(t => t.tenderId); + AND.push({ id: { in: favIds.length ? favIds : ['__none__'] } }); +} +``` + +### Anti-Patterns to Avoid +- **`forTenant()`/RLS für Triage/Saved-Search:** Kein Modultabellen-Modell nutzt RLS; das erzeugt inkonsistente Infrastruktur. Manuelles `where:{userId}` (FavoritesService) ist die Konvention. +- **Client-seitiges Filtern der Gesamtliste:** Niemals alle Tender laden und im Browser filtern — serverseitig `where` bauen. +- **Wertfilter ohne NULL-`OR`:** Würde 91,6 % der Daten verschwinden lassen (verletzt D-05). +- **`@Get(':id')` vor statischen Routen:** 404-Shadowing (Phase-10-Bug) — neue statische Routen wie `saved-searches`/`triage` VOR `@Get(':id')` deklarieren. +- **Detail als Next.js-Unterroute unter dem dynamic-loader-Mount:** Module werden als EINE `dynamic(ssr:false)`-Komponente unter `[category]/[moduleSlug]` gemountet — Sub-Routing ist nicht sauber erreichbar. Detail als In-Component-View (`?tender=`)/Drawer lösen. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Filter-`where`-Zusammenbau | String-konkatenierte SQL-`WHERE` | Prisma konditionaler `where`-Builder | Typsicher, injection-frei, testbar. | +| Case-insensitive Keyword | `toLowerCase()`-Vergleich im JS über alle Zeilen | Prisma `contains … mode:'insensitive'` (ILIKE) | DB-seitig, paginierbar. | +| Pagination/Bounds | eigene Skip/Take-Logik ohne Limit | Bestehendes `TenderQueryDto`-Muster (`@Max(100)`) | T-10-15 DoS-Schutz bereits etabliert. | +| Per-user Scoping | eigene Ownership-Checks pro Route | `FavoritesService`-Muster (userId im `where`, Ownership vor Mutation) | T-08-06 bereits verifiziert. | +| Bundesland-Zuordnung | PLZ→Bundesland-Heuristik (unsauber, PLZ-Zonen kreuzen Grenzen) | NUTS-1-Präfix aus `region` (DE1..DEG → 16 Länder) | Zuverlässig, deterministisch, Datenquelle vorhanden (933 Zeilen `region`). | +| NUTS-Region-Verständnis | eigene Interpretation der Codes | Feste NUTS-1-Map (16 Einträge) | Standardisiertes EU-Schema, stabil. | + +**Key insight:** Fast alles ist ein Config-/Query-Problem, kein Algorithmus-Problem — der Wert steckt im korrekten Umgang mit den NULL-lastigen Realdaten, nicht in Custom-Logik. + +## Runtime State Inventory + +> Diese Phase ist **kein** Rename/Refactor, aber sie führt eine **Backfill-Datenmigration** ein (Bundesland/CPV-Divisionen). Relevante Zustände: + +| Category | Items Found | Action Required | +|----------|-------------|------------------| +| Stored data | `Tender.bundesland` = 100 % NULL (1671/1671); `Tender.region` NUTS-Codes in 933 Zeilen; `cpvCodes` inkonsistent formatiert | **Backfill-Migration**: bundesland aus region-NUTS ableiten; optional cpvDivisions ableiten. Normalizer zusätzlich für neue Ingests anpassen (Code-Edit). | +| Stored data | `Tender.rawPayload` = 100 % NULL | Keine Dokument-URLs verfügbar → UI-02 nur `sourceUrl`/Portal-Link (Open Question 1). Kein Backfill möglich (Payload nie gespeichert). | +| Live service config | Keiner — Phase ändert keine externen Service-Configs. | None. | +| OS-registered state | Keiner. | None — verifiziert (reine App-Feature-Phase). | +| Secrets/env vars | Keiner — Tender-Daten & Triage tragen keine Secrets (`tender-radar-api.ts` T-10-18). | None. | +| Build artifacts | Prisma-Client muss nach Schema-Erweiterung neu generiert werden (`prisma generate`); neue Migration unter `apps/api/prisma/migrations/`. | `prisma migrate` + Client-Regenerierung; Docker-Rebuild der API. | + +**Nichts gefunden bei „Live service config", „OS-registered state", „Secrets": explizit verifiziert.** + +## Common Pitfalls + +### Pitfall 1: `bundesland` ist zu 100 % NULL +**What goes wrong:** Ein Bundesland-Filter auf die `bundesland`-Spalte liefert IMMER 0 Treffer — sieht aus wie ein Bug, ist aber leere Datenspalte. +**Why it happens:** Der Phase-10-Normalizer setzt `bundesland = null` und vertagt NUTS→Bundesland ausdrücklich auf Phase 11 (`tender-normalizer.service.ts:36-38`, verifiziert). +**How to avoid:** Bundesland aus `region`-NUTS-1-Präfix ableiten (DE1=Baden-Württemberg … DEA=Nordrhein-Westfalen … DEG=Thüringen). Empfohlen: Normalizer anpassen (neue Ingests) + Backfill-Migration (Bestand) → dann auf indexierter `bundesland`-Spalte filtern. Zwischenlösung ohne Backfill: `region startsWith nutsPrefix`. +**Warning signs:** Bundesland-Dropdown zeigt nur „(leer)"; Filter ergibt konstant 0. + +### Pitfall 2: CPV-Formate sind inkonsistent +**What goes wrong:** Exakter Match `cpvCodes has '45000000'` verfehlt Zeilen mit "45" oder "45000000-7". +**Why it happens:** DÖE liefert CPV mal 2-stellig, mal 8-stellig, mal mit Prüfziffer ("-7"). 638 distinct Werte, gemischt (verifiziert). +**How to avoid:** Beidseitig auf führende Ziffern normalisieren und Präfix-matchen (Division/Gruppe). Nie exakter Gleichheits-Match für den Hierarchie-Filter. +**Warning signs:** „Bauarbeiten"-Filter (45) übersieht offensichtliche Baulose. + +### Pitfall 3: Wertfilter eliminiert stillschweigend NULL-Werte +**What goes wrong:** `estimatedValue: { gte, lte }` schließt via SQL-NULL-Semantik alle 1530 NULL-Zeilen aus → Liste kollabiert auf ~8 %. +**Why it happens:** `NULL >= x` ist in SQL `unknown` → Zeile fällt raus. Kein Fehler, aber verletzt D-05. +**How to avoid:** `OR:[{estimatedValue:range},{estimatedValue:null}]` mit „ohne Wertangabe einschließen"-Toggle (Default an); NULL-Zeilen im UI als „keine Wertangabe" markieren. +**Warning signs:** Sobald irgendein Wertfilter aktiv ist, wird die Liste drastisch kürzer. + +### Pitfall 4: Falsches Scoping-Muster für neue Tabellen +**What goes wrong:** `forTenant()` (RLS) für Triage/Saved-Search verwenden, obwohl kein Modultabellen-Modell RLS nutzt → Transaktions-Overhead + inkonsistente Architektur; per-user-Isolation fehlt (RLS scoped nur tenant, nicht user). +**Why it happens:** CONTEXT erwähnt `forTenant`; RLS-Policies existieren real aber nur für `User`/`PasswordResetToken`/`LdapConfig`/`LdapFieldMapping` (verifiziert in `20260618112133_rls_policies`). +**How to avoid:** `FavoritesService`-Muster: Modell trägt `userId` + `tenantId`; jede Query `where:{userId}`; Ownership vor Mutation prüfen. Für per-user Daten ist userId-Scoping ohnehin strenger als tenant-RLS. +**Warning signs:** `$transaction`/`set_config` im Triage-Service; ein Nutzer sieht Triage eines anderen im selben Tenant. + +### Pitfall 5: Route-Order 404-Shadowing (Phase-10-Bug wiederholt) +**What goes wrong:** Neue `GET /modules/tender-radar/saved-searches` nach `@Get(':id')` → „saved-searches" wird als `:id` interpretiert → 404/falscher Handler. +**How to avoid:** Alle statischen Sub-Routen (`saved-searches`, `triage`, `coverage`) VOR `@Get(':id')` deklarieren — exakt wie `source-config` bereits korrekt platziert ist. +**Warning signs:** GET auf statische Route liefert „Tender not found". + +### Pitfall 6: Retention löscht Tender → verwaiste Triage/Saved-Search-Referenzen +**What goes wrong:** Phase-10-Retention (90 Tage) löscht Tender-Zeilen; Triage-Zeilen mit `tenderId` würden verwaisen. +**How to avoid:** `TenderTriage.tender Tender @relation(..., onDelete: Cascade)` — Triage verschwindet mit dem Tender. Saved-Search speichert nur Filter-Kriterien (kein Tender-FK), daher unkritisch. +**Warning signs:** Detailansicht eines gemerkten Tenders wirft „not found" nach Retention. + +## Code Examples + +### Neues Modell (Prisma) — nach FavoritesService-Muster +```prisma +// FILTER-06 — per-user Suchprofil (kein RLS; userId-Scoping im Service) +model TenderSavedSearch { + id String @id @default(uuid()) + userId String + tenantId String + name String + filters Json // serialisierte Filterkombination (deckungsgleich mit URL-Params) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, name]) // keine zwei Profile gleichen Namens pro Nutzer + @@index([userId]) +} + +// UI-03/04 — per-user Triage-Zustand pro Tender +model TenderTriage { + id String @id @default(uuid()) + userId String + tenantId String + tenderId String + isRead Boolean @default(false) + isFavorite Boolean @default(false) + readAt DateTime? + favoritedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tender Tender @relation(fields: [tenderId], references: [id], onDelete: Cascade) + + @@unique([userId, tenderId]) // Upsert-Target; ein Triage-Row je (user,tender) + @@index([userId]) + @@index([tenderId]) +} +// Auf Tender ergänzen: triage TenderTriage[] (Back-Relation, sonst keine Änderung an Tender-Semantik) +``` + +### DTO-Erweiterung (Auszug) — validiert Filter-Params +```typescript +// tender-query.dto.ts ERWEITERN (bestehende page/limit/status behalten) +@IsOptional() @IsString() @MaxLength(200) q?: string; // FILTER-01 +@IsOptional() @IsString() @MaxLength(5) plz?: string; // FILTER-02 +@IsOptional() @IsString() bundesland?: string; // FILTER-02 (NUTS-abgeleitet) +@IsOptional() @IsArray() @IsString({ each: true }) cpv?: string[]; // FILTER-03 (Divisionen) +@IsOptional() @Type(() => Date) @IsDate() deadlineFrom?: Date; // FILTER-04 +@IsOptional() @Type(() => Date) @IsDate() deadlineTo?: Date; +@IsOptional() @Type(() => Boolean) @IsBoolean() openOnly?: boolean; // FILTER-04 default true +@IsOptional() @Type(() => Number) @IsNumber() valueMin?: number; // FILTER-05 +@IsOptional() @Type(() => Number) @IsNumber() valueMax?: number; +@IsOptional() @Type(() => Boolean) @IsBoolean() includeNullValue?: boolean; // D-05 default true +@IsOptional() @IsIn(['deadline','value','published']) sort?: string; // UI-01 +@IsOptional() @Type(() => Boolean) @IsBoolean() favOnly?: boolean; // UI-04 +``` + +### NUTS-1 → Bundesland Map +```typescript +// geo/nuts-bundesland.ts — DE + ein Zeichen = NUTS-1 (Bundesland) +export const NUTS1_BUNDESLAND: Record = { + DE1: 'Baden-Württemberg', DE2: 'Bayern', DE3: 'Berlin', DE4: 'Brandenburg', + DE5: 'Bremen', DE6: 'Hamburg', DE7: 'Hessen', DE8: 'Mecklenburg-Vorpommern', + DE9: 'Niedersachsen', DEA: 'Nordrhein-Westfalen', DEB: 'Rheinland-Pfalz', + DEC: 'Saarland', DED: 'Sachsen', DEE: 'Sachsen-Anhalt', + DEF: 'Schleswig-Holstein', DEG: 'Thüringen', +}; +export const bundeslandFromRegion = (r?: string | null) => + r ? NUTS1_BUNDESLAND[r.slice(0, 3).toUpperCase()] ?? null : null; +// Reverse (Filter): Bundesland-Name → NUTS-1-Präfix für region startsWith. +``` + +### Frontend: URL-Param-getriebener Client (Auszug) +```typescript +// tender-radar-api.ts ERWEITERN — Plain fetch, wie fetchSourceConfig() +export async function listTenders(params: URLSearchParams) { + const res = await fetch(`${API_URL}/modules/tender-radar?${params}`, { credentials: 'include' }); + if (!res.ok) throw new Error('Failed to fetch tenders'); + return res.json() as Promise<{ items: Tender[]; total: number; page: number; limit: number }>; +} +// FilterPanel schreibt in useRouter().replace(`?${params}`); ResultsList liest useSearchParams(). +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| ILIKE-Full-Scan für Keyword | pg_trgm GIN-Index | erst ab ~50–100k Zeilen relevant | Bei 1671 Zeilen unnötig; als zukünftige Optimierung vormerken, nicht jetzt bauen. | +| `bundesland` roh aus Quelle | NUTS-1-Ableitung | diese Phase (in P10 vertagt) | Bundesland-Filter überhaupt erst funktionsfähig. | +| Client-State-Libs für Filter | URL searchParams (App Router) | Next.js App-Router-Ära | Teilbar, deep-linkbar, serialisierbar für Saved-Search. | + +**Deprecated/outdated:** +- Annahme, `rawPayload` enthalte Notice-Dokumente: **falsch** — Spalte ist zu 100 % NULL. +- Annahme, TanStack Query sei verfügbar (CLAUDE.md): **nicht installiert**; Plain fetch nutzen. + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | NUTS-1-Präfix (DE + 1 Zeichen) mappt eindeutig auf die 16 Bundesländer | Pitfall 1 / Code Examples | Falsche Bundesland-Zuordnung bei Filter — mittleres Risiko; NUTS-Schema ist stabil und öffentlich, sollte aber gegen 2–3 Stichproben validiert werden. | +| A2 | Ein statischer CPV-Divisions/Gruppen-Katalog (2-/3-stellig, DE-Labels) genügt für „hierarchisch + Autocomplete" (FILTER-03) | Pattern 3 | Falls Nutzer bis auf 8-stellige Codes zoomen wollen, reicht der Kurzkatalog nicht → Vollkatalog nötig (Open Question 2). | +| A3 | „nur noch offene" schließt NULL-Deadlines ein (nicht verstecken) | Pattern 1 / FILTER-04 | Falls Fachlogik NULL-Deadline als „nicht offen" wertet, ändert sich der Default-View um 281 Zeilen — Plan-Entscheidung. | +| 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 + +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+). + +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. + +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). + +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. + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| PostgreSQL (Container `tessera-ctl-db-1`, DB `tessera`) | Alle Queries + Backfill-Migration | ✓ | 16 (libc/en_US.utf8) | — | +| Prisma CLI (migrate/generate) | Schema-Erweiterung | ✓ (Projekt-Dependency) | 7.x | — | +| Node/pnpm Monorepo | Build/Test | ✓ | — | — | +| Vitest | Web- + API-Tests | ✓ | 3.x | — | + +**Hinweis Migrationen (aus MEMORY):** DB hat keinen Host-Port; lokale Prisma-Migration vom Host via Container-IP + `tessera:tessera_dev`, oder direkt `docker exec tessera-ctl-db-1 psql -U tessera -d tessera`. Kein Docker-Deploy auf dem Testserver durch Claude. + +**Missing dependencies with no fallback:** keine. +**Missing dependencies with fallback:** keine. + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | Vitest 3.x (Web + API) | +| Config file | vorhanden (Web `test: "vitest run"`; API `.spec.ts` neben Services) | +| Quick run command | `pnpm --filter @tessera/api vitest run src/tenders` (Backend-Slice) | +| Full suite command | `pnpm -r test` | + +### Phase Requirements → Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| FILTER-01 | keyword contains title+buyerName insensitive | unit | `vitest run tender-query.builder.spec` | ❌ Wave 0 | +| FILTER-05 | NULL-Wert bleibt bei aktivem Wertfilter sichtbar | unit | `vitest run tender-query.builder.spec` | ❌ Wave 0 | +| FILTER-02 | bundesland → NUTS-Präfix; region startsWith | unit | `vitest run nuts-bundesland.spec` | ❌ Wave 0 | +| FILTER-03 | CPV-Präfix matcht "45"/"45000000"/"45000000-7" | unit | `vitest run cpv-catalog.spec` | ❌ Wave 0 | +| FILTER-04 | openOnly schließt NULL-Deadline ein | unit | `vitest run tender-query.builder.spec` | ❌ Wave 0 | +| FILTER-06 | Saved-Search userId-Scoping + `@@unique([userId,name])` | integration | `vitest run tender-saved-search.service.spec` | ❌ Wave 0 | +| UI-03/04 | Triage upsert, Merklisten-Filter, Cascade-Delete | integration | `vitest run tender-triage.service.spec` | ❌ Wave 0 | +| UI-01 | Sort-Whitelist + Pagination-Bounds | unit | `vitest run tenders.controller.spec` (erweitern) | ⚠️ erweitern | +| UI-02/05 | Detail-Render + Coverage-Banner | component | `vitest run ResultsList.test / CoverageBanner.test` | ❌ Wave 0 | + +### Sampling Rate +- **Per task commit:** betroffener Slice-Spec (`vitest run `) +- **Per wave merge:** `pnpm --filter @tessera/api test && pnpm --filter test` +- **Phase gate:** `pnpm -r test` grün vor `/gsd-verify-work` + +### Wave 0 Gaps +- [ ] `tender-query.builder.spec.ts` — FILTER-01/04/05 where-Bau (NULL-Graceful ist der Kern-Test) +- [ ] `nuts-bundesland.spec.ts` — NUTS-1-Ableitung inkl. NULL-region +- [ ] `cpv-catalog.spec.ts` — Präfix-Normalisierung über inkonsistente Formate +- [ ] `tender-saved-search.service.spec.ts` + `tender-triage.service.spec.ts` — userId-Scoping, Ownership, Cascade +- [ ] Component-Tests `ResultsList` / `CoverageBanner` (Testing-Library-Muster wie `SourceConfigForm.test.tsx`) + +## Security Domain + +### Applicable ASVS Categories +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes (indirekt) | Globaler JwtAuthGuard + TenantGuard bereits aktiv; Triage/Saved-Search-Routen erben Auth. | +| V3 Session Management | no | Cookie-Session unverändert. | +| V4 Access Control | **yes** | Per-user Scoping: jede Triage/Saved-Search-Query `where:{userId}`; Ownership vor Mutation prüfen (FavoritesService T-08-06). Tender-Read weiterhin nur `@UseModule`-gated (global, D-03). | +| V5 Input Validation | **yes** | Erweitertes `TenderQueryDto` mit class-validator (`@Max`, `@IsIn`, `@MaxLength`); Sort-Whitelist statt beliebigem Feld; Pagination-Bounds (T-10-15). | +| V6 Cryptography | no | Tender/Triage/Saved-Search tragen keine Secrets (T-10-18). | + +### Known Threat Patterns for NestJS/Prisma + Next.js +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| IDOR auf fremde Triage/Saved-Search | Elevation/Info-Disclosure | Query-Scoping `where:{userId}`; keine ID-only-Lookups ohne userId. | +| SQL/Query-Injection via Filter-Param | Tampering | Prisma parametrisiert; falls CPV-Raw-SQL genutzt wird, ausschließlich parametrisiert (`$queryRaw`-Template, kein `$queryRawUnsafe` mit Interpolation). | +| Unbounded Query / DoS | Denial of Service | `limit @Max(100)` beibehalten; favOnly-`in`-Liste begrenzen. | +| Sort/Filter-Feld-Injection | Tampering | Sort-Whitelist (SORT_MAP), keine dynamischen `orderBy`-Keys aus User-Input. | +| Cross-tenant Leak bei neuen Tabellen | Info-Disclosure | tenantId + userId setzen; per-user Scoping ist strenger als tenant-RLS — dennoch tenantId mitführen für spätere Tenant-Isolation. | + +## Sources + +### Primary (HIGH confidence) +- Live-DB `tessera-ctl-db-1` / DB `tessera` — Tender-Statistiken (1671 Zeilen, NULL-Verteilungen, CPV/region-Samples) [VERIFIED: docker exec psql] +- `apps/api/prisma/schema.prisma` — Tender + bestehende Modelle, Indizes [VERIFIED: codebase] +- `apps/api/src/tenders/tenders.controller.ts` / `tenders.module.ts` / `dto/tender-query.dto.ts` — Read-Endpunkte + Route-Order-Konvention [VERIFIED: codebase] +- `apps/api/src/tenders/tender-normalizer.service.ts` — NUTS→Bundesland auf Phase 11 vertagt (`bundesland=null`) [VERIFIED: codebase] +- `apps/api/src/favorites/favorites.service.ts` + `favorites.controller.ts` — per-user Scoping-Muster (T-08-06) [VERIFIED: codebase] +- `apps/api/src/prisma/prisma-tenant.extension.ts` + `migrations/20260618112133_rls_policies/migration.sql` — RLS nur für Auth-Tabellen [VERIFIED: codebase] +- `apps/web/src/lib/tender-radar-api.ts`, `.../tender-radar/page.tsx`, `settings/components/SourceConfigForm.tsx`, `apps/web/src/lib/module-loader.ts`, `dkv-fleet/page.tsx` — Frontend-Muster (Plain fetch, dynamic-loader) [VERIFIED: codebase] +- `apps/web/package.json` — TanStack NICHT installiert; next-intl + zustand vorhanden [VERIFIED: codebase] + +### Secondary (MEDIUM confidence) +- CONTEXT.md D-01..D-12, ROADMAP § Phase 11, REQUIREMENTS FILTER/UI — Vorgaben [CITED: .planning/…] + +### Tertiary (LOW confidence) +- NUTS-1 → Bundesland Zuordnung [ASSUMED: Trainingswissen, öffentliches EU-Schema — gegen Stichproben validieren] +- EU-CPV-Divisionsbezeichnungen (DE-Labels) [ASSUMED: gegen offizielle CPV-Liste zu verifizieren beim Katalog-Bau] + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — alles im Repo verifiziert, keine Neuinstallation. +- Architecture (Query-Builder, neue Modelle, Scoping): HIGH — an FavoritesService/RLS-Migration/Controller belegt. +- Datenbefunde (NULL-Verteilungen, NUTS, CPV-Formate): HIGH — Live-DB abgefragt. +- CPV-Katalog-Labels & NUTS-Namen: MEDIUM/LOW — als ASSUMED markiert, beim Bau zu validieren. + +**Research date:** 2026-07-21 +**Valid until:** 2026-08-20 (stabile interne Codebase; Datenverteilungen können sich durch weitere Ingests leicht verschieben — die NULL-Dominanz bei estimatedValue/bundesland bleibt strukturell).