docs(11-06): complete Persönliche Suchprofile plan

This commit is contained in:
2026-07-21 16:53:08 +02:00
parent dd3ff9ea30
commit f7a2bfd3d8
4 changed files with 208 additions and 14 deletions
+2 -2
View File
@@ -31,7 +31,7 @@
- [x] **FILTER-03**: Nutzer kann nach CPV-Code / Branche filtern (hierarchische Auswahl mit Autocomplete).
- [x] **FILTER-04**: Nutzer kann nach Abgabefrist filtern, inkl. Option „nur noch offene".
- [x] **FILTER-05**: Nutzer kann nach geschätztem Auftragswert (min/max) filtern; Ausschreibungen ohne Wertangabe werden korrekt behandelt.
- [ ] **FILTER-06**: Nutzer kann Suchprofile (Kombination aus Filterkriterien) speichern, bearbeiten und löschen — pro Nutzer, mandantenbewusst.
- [x] **FILTER-06**: Nutzer kann Suchprofile (Kombination aus Filterkriterien) speichern, bearbeiten und löschen — pro Nutzer, mandantenbewusst.
### UI — Anzeige & Triage
@@ -86,7 +86,7 @@
| FILTER-03 | Phase 11 | Complete |
| FILTER-04 | Phase 11 | Complete |
| FILTER-05 | Phase 11 | Complete |
| FILTER-06 | Phase 11 | Pending |
| FILTER-06 | Phase 11 | Complete |
| UI-01 | Phase 11 | Complete |
| UI-02 | Phase 11 | Complete |
| UI-03 | Phase 11 | Complete |
+3 -3
View File
@@ -386,14 +386,14 @@ 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
5. The UI clearly indicates data coverage (Oberschwelle vs. Unterschwelle) so an empty or thin result set isn't mistaken for a bug
**Plans**: 5/6 plans executed
**Plans**: 6/6 plans executed
- [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-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-05-PLAN.md — Triage: gelesen/ungelesen + Favorit + Merklisten-Filter (UI-03/04)
- [ ] 11-06-PLAN.md — Suchprofile speichern/bearbeiten/löschen (FILTER-06)
- [x] 11-06-PLAN.md — Suchprofile speichern/bearbeiten/löschen (FILTER-06)
**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 |
| 9. Cert Manager Module | 6/6 | Complete | 2026-07-02 |
| 10. Ausschreibungs-Radar Foundation & DÖE Ingestion | 6/6 | Complete | 2026-07-21 |
| 11. Filter Engine, Results UI & Saved Searches | 5/6 | In Progress| |
| 11. Filter Engine, Results UI & Saved Searches | 6/6 | In Progress| |
| 12. Tender Notifications | 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 | - |
+12 -9
View File
@@ -4,16 +4,16 @@ milestone: v1.1
milestone_name: Ausschreibungs-Radar
current_phase: 11
current_phase_name: filter-engine-results-ui-saved-searches
status: executing
stopped_at: Completed 11-05-PLAN.md
last_updated: "2026-07-21T14:40:14.411Z"
status: verifying
stopped_at: Completed 11-06-PLAN.md
last_updated: "2026-07-21T14:52:59.205Z"
last_activity: 2026-07-21
last_activity_desc: Phase 11 execution started
progress:
total_phases: 11
completed_phases: 9
completed_phases: 10
total_plans: 52
completed_plans: 50
completed_plans: 51
---
# Project State
@@ -29,10 +29,10 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 11 (filter-engine-results-ui-saved-searches) — EXECUTING
Plan: 6 of 6
Status: Ready to execute
Status: Phase complete — ready for verification
Last activity: 2026-07-21 — Phase 11 execution started
Progress: [██████████] 96%
Progress: [██████████] 98%
## Performance Metrics
@@ -85,6 +85,7 @@ Progress: [██████████] 96%
| Phase 11 P03 | 9min | 4 tasks | 12 files |
| Phase 11 P04 | 12min | 2 tasks | 5 files |
| Phase 11 P05 | 24min | 3 tasks | 15 files |
| Phase 11 P06 | 35min | 3 tasks | 12 files |
## Accumulated Context
@@ -178,6 +179,8 @@ Recent decisions affecting current work:
- [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 ?]: TenderTriage (neu, per-user, kein forTenant/RLS) + favOnly-Sentinel-ID '__none__' für garantierten Zero-Match statt versehentlich ungefilterter Liste
- [Phase ?]: TenderSavedSearch hat keinen Tender-FK — speichert nur Filterkriterien, unkritisch bei Retention.
- [Phase ?]: page/tender-URL-Params bewusst aus dem Suchprofil-Payload ausgeschlossen (Navigations-/View-State, kein Filter-State).
### Pending Todos
@@ -215,7 +218,7 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-07-21T14:40:14.396Z
Stopped at: Completed 11-05-PLAN.md
Last session: 2026-07-21T14:52:59.192Z
Stopped at: Completed 11-06-PLAN.md
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
@@ -0,0 +1,191 @@
---
phase: 11-filter-engine-results-ui-saved-searches
plan: 06
subsystem: api
tags: [prisma, nestjs, nextjs, postgres, fetch, tender-radar, saved-search, idor]
requires:
- phase: 11-01..05
provides: Tender query builder/filter DTO, TendersController with global read + triage routes, FilterPanel/ResultsList URL-searchParams contract, TenderTriageService per-user scoping pattern
provides:
- "TenderSavedSearch Prisma model (userId+tenantId scoped, filters Json, @@unique([userId,name]))"
- "TenderSavedSearchService: userId-scoped CRUD (list/create/update/remove) with ownership checks, no forTenant()/RLS"
- "GET/POST /modules/tender-radar/saved-searches + PATCH/DELETE /saved-searches/:searchId, declared before @Get(':id')"
- "SavedSearchBar UI: save/load/rename/delete personal search profiles"
- "serializeFiltersFromSearchParams / filtersToSearchParams round-trip helpers congruent with FilterPanel's URL param names"
affects: [phase-12-notifications]
tech-stack:
added: []
patterns:
- "Per-user modul table scoping: manual where:{userId} in the service, ownership check before update/remove, collapse missing-vs-foreign-owned into the same NotFoundException (FavoritesService/TenderTriageService convention, NOT forTenant()/RLS)"
- "Prisma P2002 unique-constraint violations translated to ConflictException (409) at the service boundary"
- "URL searchParams as the single source of truth for filter state; saved searches persist a plain serialization of that same param set, explicitly excluding navigation-only params (page, tender)"
key-files:
created:
- apps/api/prisma/migrations/20260721170000_add_tender_saved_search/migration.sql
- apps/api/src/tenders/tender-saved-search.service.ts
- apps/api/src/tenders/tender-saved-search.service.spec.ts
- apps/api/src/tenders/dto/saved-search.dto.ts
- apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx
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/web/src/lib/tender-radar-api.ts
- apps/web/src/app/(portal)/modules/tender-radar/page.tsx
key-decisions:
- "TenderSavedSearch carries NO Tender foreign key — it stores only the filter criteria JSON, not tender ids, so it is unaffected by the 90-day Tender retention job (Pitfall 6)."
- "page and tender URL params are deliberately excluded from the saved filters payload — they are navigation/view state, not filter state."
- "Rename/create collisions on @@unique([userId,name]) are surfaced as a 409 ConflictException with a German message, not a raw 500."
requirements-completed: [FILTER-06]
coverage:
- id: D1
description: "TenderSavedSearch CRUD is scoped per-user (userId, not tenant-wide); a second profile with the same name for the same user is rejected while the same name is allowed across different users"
requirement: FILTER-06
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-saved-search.service.spec.ts (10 tests)"
status: pass
human_judgment: false
- id: D2
description: "Saved-searches routes (GET/POST/PATCH/DELETE) are wired on TendersController, declared before @Get(':id') so they cannot be route-shadowed, and derive userId/tenantId exclusively from the auth context"
requirement: FILTER-06
verification:
- kind: unit
ref: "apps/api/src/tenders/tenders.controller.spec.ts (24 tests, incl. saved-searches CRUD + route-order describe blocks)"
status: pass
human_judgment: false
- id: D3
description: "SavedSearchBar lets a user save the current URL filters as a named profile, load a profile back into the URL, rename, and delete it — filters round-trip through the exact FilterPanel URL param names"
requirement: FILTER-06
verification:
- kind: unit
ref: "apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx (7 tests)"
status: pass
human_judgment: false
- id: D4
description: "Cross-user isolation: a second user cannot see, edit, or delete another user's saved search profiles (IDOR)"
requirement: FILTER-06
verification:
- kind: manual_procedural
ref: "Two-user browser session against the local dev stack (docker rebuild required to pick up new backend code)"
status: unknown
human_judgment: true
rationale: "Cross-user visibility is a genuine two-browser-session UAT flow (Validation Architecture Manual-Only Verifications) — proven at the unit level via userId-scoping tests, but the live end-to-end cross-user check requires a running rebuilt stack and two authenticated sessions."
duration: 35min
completed: 2026-07-21
status: complete
---
# Phase 11 Plan 06: Persönliche Suchprofile (Saved Searches) Summary
**TenderSavedSearch model + userId-scoped CRUD service/routes + SavedSearchBar UI, with filters round-tripping byte-for-byte through the same URL param names FilterPanel/ResultsList already use.**
## Performance
- **Duration:** ~35 min
- **Started:** 2026-07-21T16:42:00Z
- **Completed:** 2026-07-21T17:17:00Z
- **Tasks:** 3 (Task 1 is TDD: RED+GREEN commits)
- **Files modified/created:** 12
## Accomplishments
- `TenderSavedSearch` Prisma model added (userId + tenantId scoped, `filters Json`, `@@unique([userId,name])`, `@@index([userId])`, no Tender FK) — migration `20260721170000_add_tender_saved_search` written, applied to the local dev DB (verified via `\d "TenderSavedSearch"` and `prisma migrate status` green), and `prisma generate` run.
- `TenderSavedSearchService` implements list/create/update/remove following the exact `FavoritesService`/`TenderTriageService` convention: every query scoped by `userId`, ownership verified before mutation (missing-vs-foreign-owned collapsed into the same `NotFoundException` to avoid existence leakage), and Prisma `P2002` unique violations translated into a 409 `ConflictException`.
- `TendersController` gained `GET/POST /modules/tender-radar/saved-searches` and `PATCH/DELETE /modules/tender-radar/saved-searches/:searchId` — all declared before `@Get(':id')` (Pitfall 5/T-11-16), with a regression test asserting method declaration order. `userId`/`tenantId` are derived exclusively via `extractTriageContext(req)`, never from the body.
- `tender-radar-api.ts` extended with `listSavedSearches`/`createSavedSearch`/`updateSavedSearch`/`deleteSavedSearch` (plain `fetch`, `credentials: 'include'`, matching the existing client convention).
- `SavedSearchBar.tsx` built and mounted above `FilterPanel` in `page.tsx`: save the current filters under a name, click a profile to load it (writes back into the URL via `router.replace`), rename in place, delete. `serializeFiltersFromSearchParams`/`filtersToSearchParams` are the exported round-trip helpers — they use the identical key list FilterPanel writes (`q, plz, bundesland, region, cpv, deadlineFrom/To, openOnly, valueMin/Max, includeNullValue, sort, favOnly`), deliberately excluding `page`/`tender` (navigation/view state, not filter state).
## IDOR / Ownership Mitigation (T-11-14/15/16)
- **Scoping pattern:** `TenderSavedSearchService` never uses `forTenant()`/RLS — it manually filters every query by `userId`, matching the codebase convention established by `FavoritesService` (T-08-06) and `TenderTriageService` (Plan 11-05). `tenantId` is stored on the row for future tenant-level reporting but is NOT the scoping field.
- **Never trust the client for identity:** `userId`/`tenantId` are extracted server-side in the controller via the same `extractTriageContext(req)` helper already used by the triage routes (`req.user.id` / `req.tenantId`) — the DTOs (`CreateSavedSearchDto`/`UpdateSavedSearchDto`) carry only `name`/`filters`, no identity fields, so a forged body cannot spoof ownership.
- **Ownership before mutation:** `update()`/`remove()` load the row by `id`, then compare `existing.userId !== userId` — a foreign-owned or nonexistent row both throw the same `NotFoundException`, so a client cannot distinguish "doesn't exist" from "exists but isn't yours" (prevents existence-probing).
- **Unit-proven:** `tender-saved-search.service.spec.ts` includes explicit IDOR-shaped tests — `list()` scoped to userId returns nothing for a foreign user; `update()`/`remove()` reject a foreign-owned id with `NotFoundException`. `tenders.controller.spec.ts` proves the controller wiring passes the authenticated `userId` (never a query/body value) into every service call.
- **Not yet proven:** live two-browser cross-user visibility (D4 above) — requires a rebuilt running stack; deferred to manual UAT per the Validation Architecture's "Manual-Only Verifications" table.
## Round-Trip Contract Verification (URL searchParams ↔ filters JSON)
`SavedSearchBar.test.tsx` asserts the full contract end-to-end at the unit level:
- Given a URL with `q, plz, bundesland, deadlineFrom, deadlineTo, openOnly, valueMin, valueMax, includeNullValue, sort, favOnly, cpv=45&cpv=71, page=3, tender=abc`, `serializeFiltersFromSearchParams` produces a `filters` object containing every filter key verbatim (as the literal strings the URL carried, `cpv` as an array) while `page`/`tender` are absent.
- Given a saved profile's `filters` (including a `cpv` array and `openOnly: 'false'`), `filtersToSearchParams` reconstructs a `URLSearchParams` where `params.get('q')`, `params.get('openOnly')`, and `params.getAll('cpv')` match exactly what FilterPanel itself would have written — confirming the same param names are used on both sides (no renaming, no type coercion beyond string ↔ string).
- Both directions are pure functions exported from `SavedSearchBar.tsx` for direct unit coverage, independent of the component's DOM behavior.
## Task Commits
Each task was committed atomically (Task 1 followed the TDD RED→GREEN cycle):
1. **Task 1 (RED): failing spec for TenderSavedSearchService** — `2b0b4f8` (test)
2. **Task 1 (GREEN): TenderSavedSearch model + CRUD service** — `df94d92` (feat)
3. **Task 2: saved-searches CRUD routes on TendersController** — `38fbf35` (feat)
4. **Task 3 (RED): failing spec for SavedSearchBar** — `ca843ab` (test)
5. **Task 3 (GREEN): SavedSearchBar UI** — `dd3ff9e` (feat)
**Plan metadata:** committed as part of this summary via the standard final-commit step.
## TDD Gate Compliance
Task 1 is `tdd="true"`: RED commit `2b0b4f8` (test) precedes GREEN commit `df94d92` (feat) — gate sequence verified in git log. Task 3 (SavedSearchBar) was executed with the same RED→GREEN discipline even though the plan did not mark it `tdd="true"`, for consistency with the phase's established convention (Plans 11-01..05 wrote failing specs first for all new components/services).
## Files Created/Modified
- `apps/api/prisma/schema.prisma` — added `TenderSavedSearch` model
- `apps/api/prisma/migrations/20260721170000_add_tender_saved_search/migration.sql` — table + unique index + userId index (applied to local dev DB)
- `apps/api/src/tenders/tender-saved-search.service.ts` — userId-scoped CRUD, P2002→409 translation
- `apps/api/src/tenders/tender-saved-search.service.spec.ts` — 10 tests (unique conflict, per-user scoping, ownership, rename collision)
- `apps/api/src/tenders/dto/saved-search.dto.ts` — CreateSavedSearchDto / UpdateSavedSearchDto (no identity fields)
- `apps/api/src/tenders/tenders.controller.ts` — 4 new saved-searches routes, all before `@Get(':id')`
- `apps/api/src/tenders/tenders.controller.spec.ts` — extended with saved-search wiring tests + route-order regression test; all 16 existing controller instantiations updated for the new constructor param
- `apps/api/src/tenders/tenders.module.ts` — registered `TenderSavedSearchService` as a provider
- `apps/web/src/lib/tender-radar-api.ts` — `listSavedSearches`/`createSavedSearch`/`updateSavedSearch`/`deleteSavedSearch` + types
- `apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx` — UI + round-trip helpers
- `apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx` — 7 tests (render, save/serialize, load/deserialize, rename, delete)
- `apps/web/src/app/(portal)/modules/tender-radar/page.tsx` — mounts `SavedSearchBar` above `FilterPanel`
## Decisions Made
- **No Tender FK on TenderSavedSearch** (Pitfall 6): a profile only stores the filter criteria JSON, not tender ids, so it is immune to the 90-day retention job's tender deletions — no cascade needed, no orphan risk.
- **`page`/`tender` excluded from the saved filters payload**: these are navigation/view state (current pagination page, open detail overlay), not filter state — persisting them would make a saved search reload to whatever page/detail happened to be open at save time, which is not the intended semantics.
- **Unique-constraint violations surfaced as 409, not 500**: `TenderSavedSearchService` catches Prisma's `P2002` and raises `ConflictException` with a German user-facing message ("Ein Suchprofil mit diesem Namen existiert bereits."), consistent with treating name collisions as an expected, recoverable condition rather than a server error.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Prisma `InputJsonValue` type mismatch in `TenderSavedSearchService`**
- **Found during:** Task 2 (running `tsc --noEmit` after wiring the controller)
- **Issue:** `filters: dto.filters` (typed `Record<string, unknown>`) does not structurally satisfy Prisma's `InputJsonValue` union (which includes array types), causing a TS2322 compile error on both `create()` and `update()`.
- **Fix:** Cast via `dto.filters as unknown as Prisma.InputJsonValue`, mirroring the exact convention already used in `apps/api/src/dashboard/dashboard.service.ts` for the same Json-field problem.
- **Files modified:** `apps/api/src/tenders/tender-saved-search.service.ts`
- **Verification:** `tsc --noEmit -p .` clean; `tender-saved-search.service.spec.ts` (10 tests) still green after the change.
- **Committed in:** `38fbf35` (folded into the Task 2 commit since it was caught while verifying Task 2's build)
---
**Total deviations:** 1 auto-fixed (Rule 1 — type-check bug)
**Impact on plan:** No scope creep; the fix is purely a TypeScript typing correction matching an established in-repo pattern.
## Issues Encountered
None beyond the deviation above.
## User Setup Required
None — no external service configuration required. The new migration was applied directly to the running local dev DB (`tessera-ctl-db-1`) via `docker exec ... psql` and marked applied with `prisma migrate resolve --applied` from the host (container has no host port, per project convention). **A rebuild of the `tessera-ctl-api-1` and `tessera-ctl-web-1` containers is required before the new saved-searches routes/UI are live in the running stack** — per project convention this rebuild is performed by the user, not by Claude.
## Next Phase Readiness
- FILTER-06 is code-complete and unit-verified; Phase 11's 5th ROADMAP success criterion (Suchprofile) is satisfied pending the container rebuild + live cross-user UAT (D4).
- Saved search `filters` JSON is a natural building block for Phase 12 (digest/instant notifications) — a saved search's stored filter criteria can be re-run server-side to check for new matching tenders without any schema change.
- No blockers for phase completion; remaining work is the manual two-user browser verification already flagged as a Manual-Only Verification in `11-VALIDATION.md`.
## Self-Check: PASSED
All 7 created/output files verified present on disk; all 5 task commit hashes (`2b0b4f8`, `df94d92`, `38fbf35`, `ca843ab`, `dd3ff9e`) verified present in `git log`.
---
*Phase: 11-filter-engine-results-ui-saved-searches*
*Completed: 2026-07-21*