docs(11-05): complete Persönliche Triage plan

This commit is contained in:
2026-07-21 16:40:22 +02:00
parent 1eb9e4f567
commit 421fe00ed9
4 changed files with 236 additions and 14 deletions
@@ -0,0 +1,220 @@
---
phase: 11-filter-engine-results-ui-saved-searches
plan: 05
subsystem: api+web
tags: [prisma, nestjs, nextjs, react, vitest, idor, per-user-scoping]
requires:
- phase: 11-filter-engine-results-ui-saved-searches
plan: 01
provides: "TendersController, TenderQueryDto, tender-query.builder.ts, ResultsList/FilterPanel components"
- phase: 11-filter-engine-results-ui-saved-searches
plan: 04
provides: "?tender=<id> URL contract, ResultsList row click handler"
provides:
- "TenderTriage Prisma model (per-user gelesen/ungelesen + Favorit, onDelete: Cascade to Tender)"
- "TenderTriageService: setTriage (idempotent upsert), listForUser, favoriteIds — all userId-scoped"
- "GET/PUT /modules/tender-radar/triage routes (declared before @Get(':id'))"
- "favOnly filter in TenderQueryDto/buildTenderWhere/TendersController.listTenders"
- "fetchTriage()/setTriage() in tender-radar-api.ts"
- "ResultsList: batch-merged triage state + Gelesen/Ungelesen + Favorit row toggles"
- "FilterPanel: 'Nur Favoriten/Merkliste' checkbox (favOnly URL param)"
affects: [11-06-saved-searches]
tech-stack:
added: []
patterns:
- "Per-user scoping via manual where:{userId} (FavoritesService/T-08-06 convention) — NOT forTenant()/RLS, which is reserved for auth-core tables (Pitfall 4)"
- "Optimistic UI update with revert-on-failure for both triage toggles, matching the fetch-based (no TanStack Query) client convention"
- "Sentinel id: {in: ['__none__']} guarantees a favOnly zero-match instead of an accidentally-unconstrained query when the user has no favorites"
key-files:
created:
- apps/api/prisma/migrations/20260721160000_add_tender_triage/migration.sql
- apps/api/src/tenders/tender-triage.service.ts
- apps/api/src/tenders/tender-triage.service.spec.ts
- apps/api/src/tenders/dto/tender-triage.dto.ts
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/tenders/tenders.controller.ts
- apps/api/src/tenders/tenders.controller.spec.ts
- apps/api/src/tenders/tenders.module.ts
- apps/api/src/tenders/dto/tender-query.dto.ts
- apps/api/src/tenders/tender-query.builder.ts
- apps/api/src/tenders/tender-query.builder.spec.ts
- apps/web/src/lib/tender-radar-api.ts
- apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx
key-decisions:
- "TenderTriage is a NEW model, not a reuse of FavoriteLink (per CONTEXT.md D-10 explicit instruction) — separate unique constraint (userId,tenderId) vs FavoriteLink's (userId,widgetId)-scoped design for an unrelated feature."
- "No forTenant()/RLS on TenderTriage — manual where:{userId} scoping in every query (TenderTriageService), exactly the FavoritesService/T-08-06 pattern. tenantId is stored on the row for future tenant-level reporting but is never the scoping key."
- "favOnly resolution happens in the controller (TenderTriageService.favoriteIds(userId) called BEFORE buildTenderWhere), not inside the builder itself — keeps tender-query.builder.ts's favIds parameter a pure, already-resolved input, so it stays independently unit-testable without a live DB."
- "Deviation (Rule 3, blocking): tenders.controller.spec.ts's 8 existing `new TendersController(prisma, scheduler)` call sites were updated to pass a third fake TenderTriageService argument — required because the constructor now has a mandatory dependency; without this the existing suite would not compile."
- "Deviation (Rule 3, blocking): ResultsList.test.tsx was extended (not listed in files_modified) with fetchTriage/setTriage mocks and 5 new test cases — the existing 3 tests would still pass without this (the batch-fetch failure is silently caught), but VALIDATION.md's own Requirement→Test map marks this file '⚠️ extend' for Plan 05, and the plan's own Done criteria (toggles work, optimistic update, batch-merge) are otherwise unverified by any automated test."
requirements-completed: [UI-03, UI-04]
coverage:
- id: D1
description: "setTriage upserts on @@unique([userId,tenderId]); repeated calls with identical params never create a second row (idempotent)"
requirement: "UI-03"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-triage.service.spec.ts#setTriage is idempotent"
status: pass
human_judgment: false
- id: D2
description: "Every triage query is scoped strictly by userId — a foreign user sees nothing for the same tenderId, even after another user set isRead/isFavorite on it (V4 / IDOR, T-11-10)"
requirement: "UI-03, UI-04"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-triage.service.spec.ts#listForUser scopes strictly by userId; #favoriteIds returns only tenderIds favorited by that exact user"
status: pass
human_judgment: false
- id: D3
description: "Deleting a Tender cascades to remove its TenderTriage rows (ON DELETE CASCADE) — verified against the applied migration on the live dev DB via an insert/delete/rollback transaction, not just the unit fake"
requirement: "UI-03, UI-04"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-triage.service.spec.ts#cascade: after a tender's triage rows are removed"
- kind: manual
ref: "docker exec tessera-ctl-db-1 psql — BEGIN; insert Tender+TenderTriage; DELETE Tender; TenderTriage count 1→0; ROLLBACK (no data mutated)"
status: pass
human_judgment: false
- id: D4
description: "GET/PUT /modules/tender-radar/triage are declared before @Get(':id') and are therefore never shadowed by the param route (Pitfall 5)"
requirement: "UI-03, UI-04"
verification:
- kind: unit
ref: "apps/api/src/tenders/tenders.controller.spec.ts#declares listTriage and setTriage before getTender"
status: pass
human_judgment: false
- id: D5
description: "favOnly=true resolves favoriteIds(userId) from the auth context and restricts the tender list to exactly those ids; an empty favorites list yields zero matches, never the unfiltered catalog"
requirement: "UI-04"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-query.builder.spec.ts#favOnly (5 cases incl. empty-favIds sentinel + DoS cap); apps/api/src/tenders/tenders.controller.spec.ts#listTenders favOnly wiring (3 cases)"
status: pass
human_judgment: false
- id: D6
description: "ResultsList batch-fetches triage for the visible page and merges isRead/isFavorite in; row toggles call setTriage and update optimistically with revert-on-failure; a failed triage fetch never blocks the list itself"
requirement: "UI-03, UI-04"
verification:
- kind: unit
ref: "apps/web/.../ResultsList.test.tsx — describe('ResultsList — triage') (5 cases: batch-merge, read toggle, favorite toggle + no propagation to row-click, optimistic revert on rejected setTriage, graceful degradation on failed fetchTriage)"
status: pass
human_judgment: false
- id: D7
description: "FilterPanel exposes a 'Nur Favoriten/Merkliste' checkbox writing favOnly=true into the URL; ResultsList forwards it generically (no code change needed there beyond the already-generic URLSearchParams passthrough)"
requirement: "UI-04"
verification:
- kind: unit
ref: "tsc --noEmit clean across web; full web suite (122 tests) green — no FilterPanel-specific automated test exists (none did before this plan either)"
status: pass
human_judgment: true
rationale: "The visual checkbox interaction and end-to-end filtering against the live 1671-row catalog is a manual/UAT flow (Docker rebuild required, user-executed per project rule) — the backend favOnly branch itself is fully unit-tested (D5)."
duration: 24min
completed: 2026-07-21
status: complete
---
# Phase 11 Plan 05: Persönliche Triage (gelesen/ungelesen, Favorit) Summary
**Per-user Tender-Triage — neues `TenderTriage`-Modell (userId-Scoping, kein forTenant/RLS, onDelete: Cascade), GET/PUT `/triage`-Routen vor `:id`, `favOnly`-Merklisten-Filter im Query-Builder, und Gelesen/Favorit-Toggles + Batch-Merge in der Trefferliste — IDOR-hart gegen Fremdzugriff getestet.**
## Performance
- **Duration:** 24 min
- **Started:** 2026-07-21T16:24:00+02:00
- **Completed:** 2026-07-21T16:38:22+02:00
- **Tasks:** 3 (Task 1 TDD: RED + GREEN; Task 2 + Task 3 direct auto)
- **Files modified:** 15 (4 created, 11 modified)
## Accomplishments
- **`TenderTriage` Prisma-Modell** (`schema.prisma`) + Migration `20260721160000_add_tender_triage`: `userId`, `tenantId`, `tenderId`, `isRead`, `isFavorite`, `readAt`/`favoritedAt`-Timestamps, `tender Tender @relation(..., onDelete: Cascade)`, `@@unique([userId,tenderId])`, `@@index([userId])`, `@@index([tenderId])`. Migration lokal auf `tessera-ctl-db-1` angewendet (`docker exec ... psql`, danach `prisma migrate resolve --applied` + `prisma generate` vom Host via Container-IP `172.19.0.2` + `tessera:tessera_dev`); FK-Cascade live gegen die DB verifiziert (siehe D3).
- **`TenderTriageService`** (`tender-triage.service.ts`): `setTriage` (idempotenter Upsert auf `@@unique([userId,tenderId])`, partielle Updates ohne das jeweils andere Flag zu überschreiben), `listForUser(userId, tenderIds)` (kurzschließt bei leerem Array), `favoriteIds(userId)`. Jede Query `where:{userId}` — kein `forTenant`/RLS (Pitfall 4). 8/8 Spec-Fälle grün.
- **Controller-Routen** (`tenders.controller.ts`): `GET /modules/tender-radar/triage?ids=<csv>` (Batch, IDs auf 200 begrenzt — DoS, T-11-11) und `PUT /modules/tender-radar/triage` (Upsert) — beide **vor** `@Get(':id')` deklariert (Pitfall 5, per Test abgesichert). `extractTriageContext` liest userId/tenantId ausschließlich aus dem Request-Kontext (`req.user?.id`, `req.tenantId`), niemals aus Body/Query (V4/IDOR).
- **`favOnly`-Filter**: `TenderQueryDto.favOnly` (boolean) → Controller ruft bei `favOnly=true` zuerst `triageService.favoriteIds(userId)` auf und reicht die IDs in `buildTenderWhere(dto, favIds)`; leere Favoritenliste ⇒ `{id:{in:['__none__']}}` (garantiert null Treffer statt versehentlich der ungefilterten Liste). In-Liste auf 500 IDs begrenzt (T-11-11).
- **Frontend**: `fetchTriage(ids)`/`setTriage(payload)` in `tender-radar-api.ts` (plain fetch, `credentials:'include'`, kein TanStack Query). `ResultsList.tsx` batcht die Triage der sichtbaren Zeilen nach jedem Laden, merged sie in einen lokalen State, rendert pro Zeile einen Gelesen/Ungelesen- und einen Favorit-Toggle (optimistisches Update mit Revert bei Fehler, `stopPropagation` verhindert das Öffnen der Detailansicht beim Toggle-Klick); gelesene Zeilen werden per `opacity-60` visuell gedimmt. `FilterPanel.tsx` erhält eine „Nur Favoriten/Merkliste"-Checkbox, die `favOnly=true` in die URL schreibt — `ResultsList` reicht das generisch (bereits bestehende URLSearchParams-Passthrough) an den Backend-Call durch, ohne eigenen Code dafür.
## Task Commits
1. **Task 1a: TenderTriage schema/migration + failing spec (RED)** — `7bb695d` (test)
2. **Task 1b: TenderTriageService (GREEN) + Migration angewendet** — `58b0f3d` (feat)
3. **Task 2: GET/PUT /triage-Routen + favOnly-Filter** — `5f97eca` (feat)
4. **Task 3: Frontend Read/Fav-Toggles + Merklisten-Filter + Batch-Merge** — `1eb9e4f` (feat)
## Files Created/Modified
- `apps/api/prisma/schema.prisma` — `TenderTriage`-Modell + Back-Relation auf `Tender`
- `apps/api/prisma/migrations/20260721160000_add_tender_triage/migration.sql` — CreateTable + FK Cascade + Indizes (neu)
- `apps/api/src/tenders/tender-triage.service.ts` — Service (neu)
- `apps/api/src/tenders/tender-triage.service.spec.ts` — 8 Tests (neu)
- `apps/api/src/tenders/dto/tender-triage.dto.ts` — Body-DTO (neu)
- `apps/api/src/tenders/dto/tender-query.dto.ts` — `favOnly`-Feld ergänzt
- `apps/api/src/tenders/tender-query.builder.ts` — `favOnly`-Branch + `MAX_FAV_IDS`
- `apps/api/src/tenders/tender-query.builder.spec.ts` — 5 neue favOnly-Tests
- `apps/api/src/tenders/tenders.controller.ts` — `listTriage`/`setTriage`-Routen, `favOnly`-Wiring in `listTenders`
- `apps/api/src/tenders/tenders.controller.spec.ts` — Konstruktor-Updates (Deviation) + 8 neue Tests (Route-Order, Triage-Routen, favOnly-Wiring)
- `apps/api/src/tenders/tenders.module.ts` — `TenderTriageService` als Provider
- `apps/web/src/lib/tender-radar-api.ts` — `fetchTriage`/`setTriage` + Typen
- `apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx` — Batch-Merge, Toggles, Dimmed-Read-Styling
- `apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx` — Mocks erweitert (Deviation) + 5 neue Tests
- `apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx` — „Nur Favoriten/Merkliste"-Checkbox
## Decisions Made
- **TenderTriage als eigenständiges neues Modell** (nicht `FavoriteLink` wiederverwenden) — exakt wie in CONTEXT.md D-10/RESEARCH.md gefordert; `FavoriteLink` ist an `WidgetInstance` gebunden (anderes Feature, andere Unique-Constraint).
- **Kein `forTenant()`/RLS** — jede Query in `TenderTriageService` scoped manuell `where:{userId}`, identisch zum `FavoritesService`-Muster (T-08-06). `tenantId` wird auf der Zeile mitgeführt (spätere Tenant-Auswertung), ist aber nie das Scoping-Feld.
- **favOnly-Auflösung im Controller, nicht im Builder**: `TendersController.listTenders` ruft `triageService.favoriteIds(userId)` auf und reicht das Ergebnis als reinen Parameter (`favIds`) an `buildTenderWhere` — der Builder bleibt dadurch eine pure Funktion, unabhängig testbar ohne Prisma/DB-Mock für den Triage-Service.
- **Sentinel-ID `'__none__'`** statt leerem `in:[]` — Prisma behandelt ein leeres `in:[]` uneinheitlich je nach Kontext; ein garantiert nie existierender String-Wert ist die robustere "garantiert kein Treffer"-Semantik.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] `tenders.controller.spec.ts`-Konstruktoraufrufe erweitert (nicht in `files_modified` von Task 2 gelistet)**
- **Found during:** Task 2 (Controller-Erweiterung)
- **Issue:** `TendersController` erhält mit Task 2 eine dritte Pflicht-Dependency (`TenderTriageService`). Die 8 bestehenden `new TendersController(prisma, scheduler)`-Aufrufe im Spec-File hätten sonst nicht mehr kompiliert (`tsc --noEmit` schlägt fehl: "Expected 3 arguments, but got 2").
- **Fix:** `makeFakeTriageService()`-Helper ergänzt, alle 8 Konstruktor-Aufrufe um das dritte Argument erweitert (identisches Muster über `replace_all`, da die betroffenen Zeilen textidentisch waren); zusätzlich 8 neue Tests für Route-Order, Triage-Routen und favOnly-Wiring ergänzt.
- **Files modified:** `apps/api/src/tenders/tenders.controller.spec.ts`
- **Commit:** `5f97eca`
**2. [Rule 3 - Blocking] `ResultsList.test.tsx` erweitert (nicht in `files_modified` von Task 3 gelistet)**
- **Found during:** Task 3 (Frontend-Verdrahtung)
- **Issue:** Der Plan fordert (Done-Kriterium): „Gelesen/Favorit-Toggles wirken und persistieren pro Nutzer; ... Triage-Zustand erscheint gemerged." `11-VALIDATION.md`s eigene Requirement→Test-Map markiert `ResultsList.test.tsx` für Zeile 11-05-03 explizit als „⚠️ extend". Ohne Erweiterung wäre keine dieser Verhaltensweisen automatisiert verifiziert (die bestehenden 3 Tests hätten zwar weiter grün bleiben — der neue `fetchTriage`-Call wird defensiv mit `Array.isArray`-Check abgefangen —, aber Toggle-Klick, Optimistic-Update-Revert und Batch-Merge blieben ungetestet).
- **Fix:** Mocks für `fetchTriage`/`setTriage` ergänzt (analog zu `mockListTenders`); 5 neue Tests: Batch-Merge-Rendering, Read-Toggle-Klick, Favorit-Toggle-Klick (inkl. `stopPropagation`-Nachweis gegen den Zeilen-Klick), Revert bei fehlgeschlagenem `setTriage`, graceful Degradation bei fehlgeschlagenem `fetchTriage`. Zusätzlich `aria-label` auf beiden Toggle-Buttons ergänzt (das ursprüngliche `title`-Attribut allein bestimmt nicht den von Testing Library berechneten Accessible Name, wenn sichtbarer Text vorhanden ist — ohne `aria-label` wären die neuen `getByRole('button', {name: ...})`-Queries fehlgeschlagen).
- **Files modified:** `apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx`, `apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx` (aria-label-Ergänzung)
- **Commit:** `1eb9e4f`
## Auth Gates
None.
## Issues Encountered
- Erster GREEN-Testlauf schlug fehl (`prisma._simulateTenderCascadeDelete is not a function`) — Tippfehler im eigenen Test (Methode war auf `prisma.tenderTriage` definiert, Aufruf erfolgte auf `prisma`). Sofort korrigiert (Rule 1, im selben Zug wie die TDD-GREEN-Verifikation, kein separater Deviation-Eintrag da reiner Test-Autor-Fehler vor dem ersten grünen Lauf).
## User Setup Required
- **Docker-Rebuild ausstehend** (wie bereits in 11-04 dokumentiert): der laufende Stack (`tessera-ctl-api-1`, `tessera-ctl-web-1`) verwendet noch alte Images. Für eine Live-Verifikation im Browser (Toggle-Klicks, Merklisten-Filter-Checkbox, Cross-User-Isolation mit zwei Testnutzern) ist `docker compose build api web && docker compose up -d` erforderlich (User führt Docker-Rebuild selbst aus — projektinterne Regel, kein Docker-Deploy durch Claude).
- Keine neuen npm-Pakete (RESEARCH Package Legitimacy Audit: keine Neuinstallation für diese Phase).
## Next Phase Readiness
- `favOnly`-URL-Param-Konvention (`favOnly=true`) ist etabliert und folgt exakt demselben Muster wie `openOnly`/`includeNullValue` — Plan 11-06 (Saved Searches) kann `favOnly` beim Serialisieren eines Suchprofils einfach mit übernehmen, ohne Sonderbehandlung.
- `TenderTriageService.favoriteIds(userId)` ist als eigenständige, testbare Methode verfügbar — falls ein künftiges Dashboard-Widget "meine Favoriten" unabhängig von der Trefferliste braucht, kann es diese Methode direkt wiederverwenden.
- **Manuelle UAT ausstehend** (siehe „User Setup Required"): alle automatisierten Tests sind grün (149 API-Tests, 122 Web-Tests), `tsc --noEmit` fehlerfrei in beiden Paketen, Migration + Cascade-Verhalten live gegen die Dev-DB verifiziert — die eigentliche Zwei-Nutzer-Cross-Isolation im Browser (D-11) ist ein Multi-User-Flow und daher laut `11-VALIDATION.md` explizit als Manual-Only-Verifikation vorgesehen.
---
*Phase: 11-filter-engine-results-ui-saved-searches*
*Completed: 2026-07-21*
## Self-Check: PASSED
All 15 created/modified files verified present on disk; all 4 task commits (7bb695d, 58b0f3d, 5f97eca, 1eb9e4f) verified in git log. Migration applied and cascade behavior verified live against tessera-ctl-db-1 (insert/delete/rollback transaction, no data mutated). Full API suite (149 tests, 13 files) and full web suite (122 tests, 22 files) green; `tsc --noEmit` clean in both packages.