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
+4 -4
View File
@@ -37,8 +37,8 @@
- [x] **UI-01**: Nutzer sieht eine durchsuchbare, sortierbare Trefferliste (Sortierung nach Frist, Wert, Veröffentlichungsdatum). - [x] **UI-01**: Nutzer sieht eine durchsuchbare, sortierbare Trefferliste (Sortierung nach Frist, Wert, Veröffentlichungsdatum).
- [x] **UI-02**: Nutzer öffnet eine Detailansicht einer Ausschreibung mit Link zur Quelle und ggf. Dokument-URLs (keine lokale Spiegelung der Vergabeunterlagen). - [x] **UI-02**: Nutzer öffnet eine Detailansicht einer Ausschreibung mit Link zur Quelle und ggf. Dokument-URLs (keine lokale Spiegelung der Vergabeunterlagen).
- [ ] **UI-03**: Nutzer kann Treffer als gelesen/ungelesen markieren (pro Nutzer). - [x] **UI-03**: Nutzer kann Treffer als gelesen/ungelesen markieren (pro Nutzer).
- [ ] **UI-04**: Nutzer kann Treffer als Favorit/Merkliste markieren und eine Merklisten-Ansicht filtern (pro Nutzer). - [x] **UI-04**: Nutzer kann Treffer als Favorit/Merkliste markieren und eine Merklisten-Ansicht filtern (pro Nutzer).
- [x] **UI-05**: Die UI weist die Abdeckung transparent aus (Oberschwelle vs. Unterschwelle), damit „keine Treffer" nicht als Fehler missverstanden wird. - [x] **UI-05**: Die UI weist die Abdeckung transparent aus (Oberschwelle vs. Unterschwelle), damit „keine Treffer" nicht als Fehler missverstanden wird.
- [ ] **UI-06**: Ausgeschlossene Portale (vergabe24, aumass) werden als „manuell zu überwachen" mit Direktlink angezeigt. - [ ] **UI-06**: Ausgeschlossene Portale (vergabe24, aumass) werden als „manuell zu überwachen" mit Direktlink angezeigt.
@@ -89,8 +89,8 @@
| FILTER-06 | Phase 11 | Pending | | FILTER-06 | Phase 11 | Pending |
| UI-01 | Phase 11 | Complete | | UI-01 | Phase 11 | Complete |
| UI-02 | Phase 11 | Complete | | UI-02 | Phase 11 | Complete |
| UI-03 | Phase 11 | Pending | | UI-03 | Phase 11 | Complete |
| UI-04 | Phase 11 | Pending | | UI-04 | Phase 11 | Complete |
| UI-05 | Phase 11 | Complete | | UI-05 | Phase 11 | Complete |
| NOTIFY-01 | Phase 12 | Pending | | NOTIFY-01 | Phase 12 | Pending |
| NOTIFY-02 | Phase 12 | Pending | | NOTIFY-02 | Phase 12 | Pending |
+3 -3
View File
@@ -386,13 +386,13 @@ Plans:
4. User can mark a tender as read/unread and as favourite, and filter the results list to only favourites -- both states are per-user, not shared across the tenant 4. User can mark a tender as read/unread and as favourite, and filter the results list to only favourites -- both states are per-user, not shared across the tenant
5. The UI clearly indicates data coverage (Oberschwelle vs. Unterschwelle) so an empty or thin result set isn't mistaken for a bug 5. The UI clearly indicates data coverage (Oberschwelle vs. Unterschwelle) so an empty or thin result set isn't mistaken for a bug
**Plans**: 4/6 plans executed **Plans**: 5/6 plans executed
- [x] 11-01-PLAN.md — Trefferliste + Freitext + Sortierung + Coverage-Banner (FILTER-01/04/05, UI-01/05) - [x] 11-01-PLAN.md — Trefferliste + Freitext + Sortierung + Coverage-Banner (FILTER-01/04/05, UI-01/05)
- [x] 11-02-PLAN.md — Bundesland-Ableitung (NUTS + Backfill) + Region/PLZ/Bundesland-Filter (FILTER-02) - [x] 11-02-PLAN.md — Bundesland-Ableitung (NUTS + Backfill) + Region/PLZ/Bundesland-Filter (FILTER-02)
- [x] 11-03-PLAN.md — CPV-Filter (Katalog + Divisionen) + Wert-Filter-UI (FILTER-03/05) - [x] 11-03-PLAN.md — CPV-Filter (Katalog + Divisionen) + Wert-Filter-UI (FILTER-03/05)
- [x] 11-04-PLAN.md — Detailansicht via ?tender=<id> + Quell-Link (UI-02) - [x] 11-04-PLAN.md — Detailansicht via ?tender=<id> + Quell-Link (UI-02)
- [ ] 11-05-PLAN.md — Triage: gelesen/ungelesen + Favorit + Merklisten-Filter (UI-03/04) - [x] 11-05-PLAN.md — Triage: gelesen/ungelesen + Favorit + Merklisten-Filter (UI-03/04)
- [ ] 11-06-PLAN.md — Suchprofile speichern/bearbeiten/löschen (FILTER-06) - [ ] 11-06-PLAN.md — Suchprofile speichern/bearbeiten/löschen (FILTER-06)
**UI hint**: yes **UI hint**: yes
@@ -462,7 +462,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
| 8. Dashboard Widgets Vollimplementierung | 4/4 | Complete | 2026-07-01 | | 8. Dashboard Widgets Vollimplementierung | 4/4 | Complete | 2026-07-01 |
| 9. Cert Manager Module | 6/6 | Complete | 2026-07-02 | | 9. Cert Manager Module | 6/6 | Complete | 2026-07-02 |
| 10. Ausschreibungs-Radar Foundation & DÖE Ingestion | 6/6 | Complete | 2026-07-21 | | 10. Ausschreibungs-Radar Foundation & DÖE Ingestion | 6/6 | Complete | 2026-07-21 |
| 11. Filter Engine, Results UI & Saved Searches | 4/6 | In Progress| | | 11. Filter Engine, Results UI & Saved Searches | 5/6 | In Progress| |
| 12. Tender Notifications | 0/TBD | Not started | - | | 12. Tender Notifications | 0/TBD | Not started | - |
| 13. Scraping Adapters & Cross-Source Deduplication | 0/TBD | Not started | - | | 13. Scraping Adapters & Cross-Source Deduplication | 0/TBD | Not started | - |
| 14. RSS, Email-Alert Ingestion & Module Rollout | 0/TBD | Not started | - | | 14. RSS, Email-Alert Ingestion & Module Rollout | 0/TBD | Not started | - |
+9 -7
View File
@@ -5,15 +5,15 @@ milestone_name: Ausschreibungs-Radar
current_phase: 11 current_phase: 11
current_phase_name: filter-engine-results-ui-saved-searches current_phase_name: filter-engine-results-ui-saved-searches
status: executing status: executing
stopped_at: Completed 11-04-PLAN.md stopped_at: Completed 11-05-PLAN.md
last_updated: "2026-07-21T14:23:31.171Z" last_updated: "2026-07-21T14:40:14.411Z"
last_activity: 2026-07-21 last_activity: 2026-07-21
last_activity_desc: Phase 11 execution started last_activity_desc: Phase 11 execution started
progress: progress:
total_phases: 11 total_phases: 11
completed_phases: 9 completed_phases: 9
total_plans: 52 total_plans: 52
completed_plans: 49 completed_plans: 50
--- ---
# Project State # Project State
@@ -28,11 +28,11 @@ See: .planning/PROJECT.md (updated 2026-07-17)
## Current Position ## Current Position
Phase: 11 (filter-engine-results-ui-saved-searches) — EXECUTING Phase: 11 (filter-engine-results-ui-saved-searches) — EXECUTING
Plan: 5 of 6 Plan: 6 of 6
Status: Ready to execute Status: Ready to execute
Last activity: 2026-07-21 — Phase 11 execution started Last activity: 2026-07-21 — Phase 11 execution started
Progress: [█████████░] 94% Progress: [██████████] 96%
## Performance Metrics ## Performance Metrics
@@ -84,6 +84,7 @@ Progress: [█████████░] 94%
| Phase 11 P02 | 4min | 3 tasks | 12 files | | Phase 11 P02 | 4min | 3 tasks | 12 files |
| Phase 11 P03 | 9min | 4 tasks | 12 files | | Phase 11 P03 | 9min | 4 tasks | 12 files |
| Phase 11 P04 | 12min | 2 tasks | 5 files | | Phase 11 P04 | 12min | 2 tasks | 5 files |
| Phase 11 P05 | 24min | 3 tasks | 15 files |
## Accumulated Context ## Accumulated Context
@@ -176,6 +177,7 @@ Recent decisions affecting current work:
- [Phase ?]: cpv-URL-Param als wiederholter Key (?cpv=45&cpv=71) statt Komma-Join; DTO normalisiert Einzelwert per @Transform zu Array - [Phase ?]: cpv-URL-Param als wiederholter Key (?cpv=45&cpv=71) statt Komma-Join; DTO normalisiert Einzelwert per @Transform zu Array
- [Phase ?]: TenderDetail fetcht selbstständig via getTender(tenderId) im useEffect (Muster SourceConfigForm); page.tsx bleibt reiner ?tender-Param-Reader - [Phase ?]: TenderDetail fetcht selbstständig via getTender(tenderId) im useEffect (Muster SourceConfigForm); page.tsx bleibt reiner ?tender-Param-Reader
- [Phase ?]: ResultsList.tsx modifiziert (nicht im Plan gelistet) — Rule 3: Zeilen-Klick-Handler war notwendig, um den Plan-eigenen key_link/Done-Kriterium zu erfüllen - [Phase ?]: ResultsList.tsx modifiziert (nicht im Plan gelistet) — Rule 3: Zeilen-Klick-Handler war notwendig, um den Plan-eigenen key_link/Done-Kriterium zu erfüllen
- [Phase ?]: TenderTriage (neu, per-user, kein forTenant/RLS) + favOnly-Sentinel-ID '__none__' für garantierten Zero-Match statt versehentlich ungefilterter Liste
### Pending Todos ### Pending Todos
@@ -213,7 +215,7 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity ## Session Continuity
Last session: 2026-07-21T14:23:31.158Z Last session: 2026-07-21T14:40:14.396Z
Stopped at: Completed 11-04-PLAN.md Stopped at: Completed 11-05-PLAN.md
Resume file: None Resume file: None
Last activity: 2026-07-14 - Built LDAP per-user exclude/denylist filter (9d1323f), migration applied on live DB, verified via Playwright: sync deactivated 4 excluded service accounts (administrator/krbtgt/guest/dns-ldap), 2 real LDAP users stay active, 0 wrongly created Last activity: 2026-07-14 - Built LDAP per-user exclude/denylist filter (9d1323f), migration applied on live DB, verified via Playwright: sync deactivated 4 excluded service accounts (administrator/krbtgt/guest/dns-ldap), 2 real LDAP users stay active, 0 wrongly created
@@ -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.