docs(11-06): complete Persönliche Suchprofile plan
This commit is contained in:
@@ -31,7 +31,7 @@
|
|||||||
- [x] **FILTER-03**: Nutzer kann nach CPV-Code / Branche filtern (hierarchische Auswahl mit Autocomplete).
|
- [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-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.
|
- [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
|
### UI — Anzeige & Triage
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
| FILTER-03 | Phase 11 | Complete |
|
| FILTER-03 | Phase 11 | Complete |
|
||||||
| FILTER-04 | Phase 11 | Complete |
|
| FILTER-04 | Phase 11 | Complete |
|
||||||
| FILTER-05 | Phase 11 | Complete |
|
| FILTER-05 | Phase 11 | Complete |
|
||||||
| FILTER-06 | Phase 11 | Pending |
|
| FILTER-06 | Phase 11 | Complete |
|
||||||
| UI-01 | Phase 11 | Complete |
|
| UI-01 | Phase 11 | Complete |
|
||||||
| UI-02 | Phase 11 | Complete |
|
| UI-02 | Phase 11 | Complete |
|
||||||
| UI-03 | Phase 11 | Complete |
|
| UI-03 | Phase 11 | Complete |
|
||||||
|
|||||||
@@ -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
|
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**: 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-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)
|
||||||
- [x] 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)
|
- [x] 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 | 5/6 | In Progress| |
|
| 11. Filter Engine, Results UI & Saved Searches | 6/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 | - |
|
||||||
|
|||||||
+12
-9
@@ -4,16 +4,16 @@ milestone: v1.1
|
|||||||
milestone_name: Ausschreibungs-Radar
|
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: verifying
|
||||||
stopped_at: Completed 11-05-PLAN.md
|
stopped_at: Completed 11-06-PLAN.md
|
||||||
last_updated: "2026-07-21T14:40:14.411Z"
|
last_updated: "2026-07-21T14:52:59.205Z"
|
||||||
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: 10
|
||||||
total_plans: 52
|
total_plans: 52
|
||||||
completed_plans: 50
|
completed_plans: 51
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
@@ -29,10 +29,10 @@ See: .planning/PROJECT.md (updated 2026-07-17)
|
|||||||
|
|
||||||
Phase: 11 (filter-engine-results-ui-saved-searches) — EXECUTING
|
Phase: 11 (filter-engine-results-ui-saved-searches) — EXECUTING
|
||||||
Plan: 6 of 6
|
Plan: 6 of 6
|
||||||
Status: Ready to execute
|
Status: Phase complete — ready for verification
|
||||||
Last activity: 2026-07-21 — Phase 11 execution started
|
Last activity: 2026-07-21 — Phase 11 execution started
|
||||||
|
|
||||||
Progress: [██████████] 96%
|
Progress: [██████████] 98%
|
||||||
|
|
||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
|
|
||||||
@@ -85,6 +85,7 @@ Progress: [██████████] 96%
|
|||||||
| 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 |
|
| Phase 11 P05 | 24min | 3 tasks | 15 files |
|
||||||
|
| Phase 11 P06 | 35min | 3 tasks | 12 files |
|
||||||
|
|
||||||
## Accumulated Context
|
## 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 ?]: 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
|
- [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
|
### Pending Todos
|
||||||
|
|
||||||
@@ -215,7 +218,7 @@ Items acknowledged and carried forward from previous milestone close:
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-07-21T14:40:14.396Z
|
Last session: 2026-07-21T14:52:59.192Z
|
||||||
Stopped at: Completed 11-05-PLAN.md
|
Stopped at: Completed 11-06-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,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