From f7a2bfd3d8a1b3fa111202f7b245adf67e44281f Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 16:53:08 +0200 Subject: [PATCH] =?UTF-8?q?docs(11-06):=20complete=20Pers=C3=B6nliche=20Su?= =?UTF-8?q?chprofile=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .planning/REQUIREMENTS.md | 4 +- .planning/ROADMAP.md | 6 +- .planning/STATE.md | 21 +- .../11-06-SUMMARY.md | 191 ++++++++++++++++++ 4 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 .planning/phases/11-filter-engine-results-ui-saved-searches/11-06-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index eda888b..13ea70e 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -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 | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 57e89e6..cfbdda7 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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= + 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 | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index ab5885f..f6e87ef 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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 diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-06-SUMMARY.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-06-SUMMARY.md new file mode 100644 index 0000000..14369f3 --- /dev/null +++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-06-SUMMARY.md @@ -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`) 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*