docs(11): phase research — live-DB findings, query patterns, new models
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
## 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)
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## 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). |
|
||||
</phase_requirements>
|
||||
|
||||
## 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=<id>)
|
||||
│ ─ FilterPanel schreibt Params ─ ResultsList liest Params
|
||||
▼
|
||||
[tender-radar-api.ts fetch(credentials:'include')]
|
||||
│
|
||||
├── GET /modules/tender-radar?<filter+sort+page> ──┐
|
||||
│ (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=<id>)
|
||||
│ ├── 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<string, Prisma.TenderOrderByWithRelationInput>;
|
||||
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=<id>`)/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<string, string> = {
|
||||
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=<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
|
||||
|
||||
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 <spec>`)
|
||||
- **Per wave merge:** `pnpm --filter @tessera/api test && pnpm --filter <web> 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).
|
||||
Reference in New Issue
Block a user