docs(11-06): complete Persönliche Suchprofile plan
This commit is contained in:
@@ -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*
|
||||
Reference in New Issue
Block a user