--- phase: 12-tender-notifications plan: 04 subsystem: api+ui tags: [nestjs, prisma, class-validator, nextjs, tender-radar, notifications] requires: - phase: 12-tender-notifications (12-01/12-02/12-03) provides: "TenderNotificationPref + instantAlert schema fields, digest scheduler (daily default due-check), instant-dispatch mail path" provides: - "GET/PUT /modules/tender-radar/notification-pref (per-user digestInterval CRUD, IDOR-safe)" - "instantAlert passthrough on saved-search create/update DTOs + service" - "Web UI: digest-interval selector (settings page) + Sofort-Alert toggle per profile (SavedSearchBar)" affects: [14-tender-i18n-and-admin-sources] tech-stack: added: [] patterns: - "Per-user pref CRUD via upsert on @@unique(userId) — same scoping convention as TenderSavedSearchService/TenderTriageService, no forTenant()/RLS" - "Route declared before @Get(':id') to avoid the NestJS route-order shadowing pitfall" key-files: created: - apps/api/src/tenders/dto/notification-pref.dto.ts - apps/api/src/tenders/tender-notification-pref.service.ts - apps/api/src/tenders/tender-notification-pref.service.spec.ts modified: - apps/api/src/tenders/dto/saved-search.dto.ts - apps/api/src/tenders/tender-saved-search.service.ts - apps/api/src/tenders/tender-saved-search.service.spec.ts - 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/settings/page.tsx" - "apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx" - "apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx" key-decisions: - "Digest-interval selector kept inline in settings/page.tsx (already a 'use client' shell) rather than split into a separate component — no standalone unit-test coverage was called for in this plan for that piece" - "instantAlert toggle uses a plain HTML checkbox with aria-label 'Sofort-Alert für {name}', not a shadcn switch, matching the existing minimal chip-based SavedSearchBar UI" patterns-established: - "Notification-pref GET/PUT routes declared immediately after the saved-searches DELETE route and before @Get(':id') — same static-route-before-param-route convention as source-config/coverage/triage/saved-searches" requirements-completed: [NOTIFY-01, NOTIFY-02] coverage: - id: D1 description: "Per-user digest interval (Täglich/Wöchentlich/Aus) is readable/writable via GET/PUT /modules/tender-radar/notification-pref, defaults to 'daily', IDOR-safe" requirement: "NOTIFY-01" verification: - kind: unit ref: "apps/api/src/tenders/tender-notification-pref.service.spec.ts#TenderNotificationPrefService (4 tests)" status: pass - kind: unit ref: "apps/api/src/tenders/tenders.controller.spec.ts#GET/PUT notification-pref (NOTIFY-01, per-user, T-12-14)" status: pass human_judgment: false - id: D2 description: "Digest-interval selector in the tender-radar settings page loads current value on mount and persists changes" requirement: "NOTIFY-01" verification: [] human_judgment: true rationale: "UI persistence-across-reload behavior is the end-of-phase UAT item (settings page reload check) — not covered by an automated component test in this plan" - id: D3 description: "Per-profile Sofort-Alert toggle (instantAlert) persists via the existing saved-search create/update path, default off" requirement: "NOTIFY-02" verification: - kind: unit ref: "apps/api/src/tenders/tender-saved-search.service.spec.ts#instantAlert passthrough (3 tests)" status: pass - kind: unit ref: "apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx#Sofort-Alert toggle (2 tests)" status: pass human_judgment: false - id: D4 description: "All notification-pref and saved-search routes derive userId/tenantId exclusively from extractTriageContext(req), never body/query (IDOR)" requirement: "NOTIFY-01" verification: - kind: unit ref: "apps/api/src/tenders/tenders.controller.spec.ts#derives userId from req.user ... never from a query param (V4/IDOR)" status: pass human_judgment: false duration: 12min completed: 2026-07-22 status: complete --- # Phase 12 Plan 04: Digest-Intervall + Sofort-Alert Bedienoberfläche Summary **Per-user digest-interval CRUD (GET/PUT notification-pref, default 'daily') plus instantAlert passthrough on saved-search create/update — both wired into a settings-page selector and a per-profile SavedSearchBar toggle, closing the loop from 12-01/02/03's pipeline to a user-controllable UI.** ## Performance - **Duration:** ~12 min - **Completed:** 2026-07-22 - **Tasks:** 3/3 completed - **Files modified:** 10 (3 created, 7 modified) ## Accomplishments - `TenderNotificationPrefService` (per-user digestInterval CRUD, default `'daily'` when no row exists, upsert on `@@unique(userId)`) + `UpdateNotificationPrefDto` (`@IsIn(['daily','weekly','off'])`) - `GET`/`PUT /modules/tender-radar/notification-pref`, declared before `@Get(':id')` (route-order pitfall avoided, regression test added) - `instantAlert` now flows through `Create/UpdateSavedSearchDto` and `TenderSavedSearchService.create/update` (optional, `@IsBoolean`, falls back to the Prisma column default `false` when omitted) - `tender-radar-api.ts`: `fetchNotificationPref`/`saveNotificationPref`, and `SavedSearch`/`CreateSavedSearchPayload`/`UpdateSavedSearchPayload` extended with `instantAlert` - Settings page: "Benachrichtigungen" section with a Täglich/Wöchentlich/Aus select, loads via `fetchNotificationPref` on mount, saves via `saveNotificationPref` on change - `SavedSearchBar`: per-profile Sofort-Alert checkbox (`aria-label="Sofort-Alert für {name}"`), reflects `profile.instantAlert`, calls `updateSavedSearch(id, { instantAlert })` + reloads on toggle ## Task Commits 1. **Task 1: Backend — Pref-Service + Routen + instantAlert-Durchreichung** - `9e7ba53` (feat, TDD RED→GREEN) 2. **Task 2: Frontend API-Client — Pref-Funktionen + instantAlert in SavedSearch** - `73a7e49` (feat) 3. **Task 3: Frontend UI — Digest-Intervall-Auswahl + Sofort-Alert-Toggle** - `d60080e` (feat) _Note: no separate metadata commit yet — this SUMMARY/STATE/ROADMAP commit follows._ ## Files Created/Modified - `apps/api/src/tenders/dto/notification-pref.dto.ts` - `UpdateNotificationPrefDto` (`@IsIn`) - `apps/api/src/tenders/tender-notification-pref.service.ts` - per-user digestInterval CRUD, default `'daily'`, upsert on `userId` - `apps/api/src/tenders/tender-notification-pref.service.spec.ts` - RED-first TDD spec (4 tests) - `apps/api/src/tenders/dto/saved-search.dto.ts` - `instantAlert?: boolean` on both Create/Update DTOs - `apps/api/src/tenders/tender-saved-search.service.ts` - passes `instantAlert` through create/update when supplied - `apps/api/src/tenders/tender-saved-search.service.spec.ts` - 3 new instantAlert passthrough tests - `apps/api/src/tenders/tenders.controller.ts` - `getNotificationPref`/`setNotificationPref` routes + constructor DI - `apps/api/src/tenders/tenders.controller.spec.ts` - fake pref service added to all 20 controller instantiations + new route-order/IDOR tests - `apps/api/src/tenders/tenders.module.ts` - registers `TenderNotificationPrefService`, doc comment updated - `apps/web/src/lib/tender-radar-api.ts` - `NotificationPref` type + `fetchNotificationPref`/`saveNotificationPref`; `instantAlert` added to `SavedSearch`/payloads - `apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx` - Benachrichtigungen section (digest-interval select) - `apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx` - per-profile Sofort-Alert checkbox - `apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx` - checkbox-state + toggle-calls-updateSavedSearch tests ## Decisions Made - Kept the digest-interval selector inline in `settings/page.tsx` rather than extracting a separate `NotificationPrefForm.tsx` component — the page is already a `'use client'` shell (unlike `SourceConfigForm`'s admin-only split), and this plan's file list/test scope didn't call for standalone unit coverage of that piece. - Used a plain checkbox (not a switch) for the Sofort-Alert toggle to match the existing minimal chip-based `SavedSearchBar` styling. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 3 - Blocking] Updated `tenders.controller.spec.ts` fakes for the new constructor parameter** - **Found during:** Task 1 (adding `TenderNotificationPrefService` to `TendersController`'s constructor) - **Issue:** Adding a 5th required constructor parameter broke all 20 existing `new TendersController(...)` call sites in the spec file (missing-argument TS errors) - **Fix:** Added a `makeFakeNotificationPrefService()` helper and appended it as the 5th argument to every existing constructor call; also added two new tests (route-declaration-order guard + GET/PUT IDOR-scoping proof) for the new routes - **Files modified:** `apps/api/src/tenders/tenders.controller.spec.ts` - **Verification:** `npx tsc --noEmit` clean; full API test suite (19 files / 209 tests) green - **Committed in:** `9e7ba53` (Task 1 commit) --- **Total deviations:** 1 auto-fixed (blocking — test compilation) **Impact on plan:** Necessary to keep the existing test suite compiling and passing after the planned constructor change. No scope creep — this is the direct, unavoidable consequence of the plan's own "Injiziere TenderNotificationPrefService in den Controller-Konstruktor" instruction. ## Issues Encountered None. ## IDOR Mitigation (T-12-14/15/16) - `UpdateNotificationPrefDto` and both saved-search DTOs carry no `userId`/`tenantId` field — both routes derive them exclusively from `extractTriageContext(req)` (cookie-backed auth context), never from body/query. - `TenderNotificationPrefService` scopes every read/write by `userId` via the `@@unique(userId)` constraint — a foreign `userId` can never read or overwrite another user's pref (proven in `tender-notification-pref.service.spec.ts`: "getForUser() is scoped strictly by userId"). - `digestInterval` is validated server-side to exactly `'daily'|'weekly'|'off'` via `@IsIn` (T-12-15) before it reaches the service/scheduler due-check logic. - `instantAlert` validated via `@IsBoolean`; ownership of the saved-search profile is still enforced in `TenderSavedSearchService.update()` (pre-existing NotFound-collapse pattern, T-11-14) — unchanged by this plan. - No new package installs; no new trust boundary beyond the two already-modeled routes (matches `12-04-PLAN.md`'s threat register — no `## Threat Flags` needed). ## Known Stubs None. ## User Setup Required None - no external service configuration required. Both `TenderNotificationPref.digestInterval` and `TenderSavedSearch.instantAlert` columns already existed in the local dev DB (seeded in Plan 12-01) — no migration needed for this plan. ## Next Phase Readiness - NOTIFY-01/02 are now fully wired end-to-end: user sets digest interval / Sofort-Alert in the web UI → the 12-01/02/03 matching/digest/instant pipeline reads and honors those settings. - Remaining end-of-phase UAT (per `12-04-PLAN.md` ``): manually confirm in the running app that (a) setting the interval to "Wöchentlich" and reloading the settings page keeps the value, and (b) toggling a profile's Sofort-Alert and reloading keeps it on. Requires a docker rebuild/restart of the web+api containers to pick up these changes (per project convention, the user performs the rebuild, not this agent). - Phase 12 is otherwise code-complete pending that UAT + docker rebuild (consistent with the phase-level pause note in `eb10bd3`). --- *Phase: 12-tender-notifications* *Completed: 2026-07-22* ## Self-Check: PASSED All 12 listed files/paths verified present on disk; all 3 task commit hashes (`9e7ba53`, `73a7e49`, `d60080e`) verified present in git log.