13 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 12-tender-notifications | 04 | execute | 3 |
|
|
true |
|
|
Purpose: NOTIFY-01 (Intervall im Webinterface konfigurierbar) + NOTIFY-02 (Sofort-Alert im Webinterface aktivierbar). Die Schema-Felder existieren bereits (12-01); dieser Plan macht sie für den Nutzer bedien- und persistierbar und schließt damit die End-to-End-Schleife: Nutzer stellt Kanal ein → Pipeline (12-01/02/03) sendet entsprechend.
Output: Pref-Service + Routen + DTOs, instantAlert-Durchreichung, erweiterte API-Client-Funktionen, Settings-Auswahl + Profil-Toggle.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>
@.planning/phases/12-tender-notifications/12-CONTEXT.md @.planning/phases/12-tender-notifications/12-RESEARCH.md @apps/api/src/tenders/tenders.controller.ts @apps/api/src/tenders/tender-saved-search.service.ts @apps/api/src/tenders/dto/saved-search.dto.ts @apps/api/src/tenders/tenders.module.ts @apps/web/src/lib/tender-radar-api.ts @apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx @apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx Task 1: Backend — Pref-Service + Routen + instantAlert-Durchreichung 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, apps/api/src/tenders/dto/saved-search.dto.ts, apps/api/src/tenders/tender-saved-search.service.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.module.ts - Test 1 (Default 'daily', D-01): getForUser(userId) ohne vorhandene Zeile liefert einen Default { digestInterval: 'daily' } (kein Fehler, kein Autowrite nötig). - Test 2 (Upsert per-user, D-03): setForUser(userId, tenantId, 'weekly') upsertet auf @@unique userId und liefert digestInterval='weekly'; ein zweiter Aufruf mit 'off' aktualisiert dieselbe Zeile. - Test 3 (Validierung, V5): das DTO akzeptiert nur 'daily'|'weekly'|'off' (@IsIn); andere Werte werden von class-validator abgewiesen. - Test 4 (instantAlert-Durchreichung, D-04): saved-search create/update mit instantAlert=true persistiert das Feld; ohne Angabe bleibt der Default false. Erstelle dto/notification-pref.dto.ts: UpdateNotificationPrefDto mit digestInterval: string, validiert via @IsIn(['daily','weekly','off']) (V5). KEIN userId/tenantId-Feld (IDOR — beide server-seitig aus Auth-Context, wie CreateSavedSearchDto-Kommentar).Erstelle tender-notification-pref.service.ts (@Injectable, constructor(prisma)) nach dem TenderSavedSearchService/TenderTriageService-Scoping-Muster (userId-Scoping, KEIN forTenant()/RLS). Methoden: getForUser(userId) → prisma.tenderNotificationPref.findUnique({ where: { userId } }); bei null einen Default { digestInterval: 'daily' } zurückgeben (D-01 — konsistent mit der Default-daily-Semantik des Digest-Schedulers). setForUser(userId, tenantId, digestInterval) → prisma.tenderNotificationPref.upsert({ where: { userId }, create: { userId, tenantId, digestInterval }, update: { digestInterval } }). Schreibe den Spec zuerst (RED) gemäß behavior-Block.
Erweitere dto/saved-search.dto.ts: füge instantAlert?: boolean (@IsOptional, @IsBoolean) zu CreateSavedSearchDto UND UpdateSavedSearchDto hinzu (D-04, V5). Erweitere TenderSavedSearchService.create/update, sodass instantAlert — falls im DTO gesetzt — in die Prisma-create/update-data übernommen wird (Default false greift, wenn nicht angegeben). Ownership-Prüfung in update bleibt unverändert (userId-Vergleich, NotFound bei fremd/fehlend).
Erweitere tenders.controller.ts um zwei Routen, deklariert VOR der @Get(':id')-Route (NestJS Route-Order-Pitfall, wie bei source-config/coverage/triage/saved-searches):
- @Get('notification-pref') @UseModule('tender-radar') → { userId } = extractTriageContext(req); return prefService.getForUser(userId).
- @Put('notification-pref') @UseModule('tender-radar') → { userId, tenantId } = extractTriageContext(req); return prefService.setForUser(userId, tenantId, dto.digestInterval). Injiziere TenderNotificationPrefService in den Controller-Konstruktor. Der instantAlert-Weg braucht KEINE neue Route — er läuft über die bestehende POST/PATCH saved-searches (DTO trägt jetzt instantAlert).
Registriere TenderNotificationPrefService als Provider in tenders.module.ts (neben den bestehenden inkl. 12-01/12-02-Providern). Aktualisiere den Modul-Doc-Kommentar. pnpm --filter api test -- tender-notification-pref.service && cd apps/api && npx tsc --noEmit -p tsconfig.json && grep -q "notification-pref" src/tenders/tenders.controller.ts Pref-Service (Default daily, per-user Upsert) + DTO (@IsIn) + GET/PUT-Routen vor :id + instantAlert in beiden saved-search-DTOs und im Service; Provider registriert; tsc fehlerfrei; Specs grün.
Task 2: Frontend API-Client — Pref-Funktionen + instantAlert in SavedSearch apps/web/src/lib/tender-radar-api.ts Erweitere tender-radar-api.ts (native fetch, credentials:'include' — kein TanStack Query, bestehendes Muster):(a) NotificationPref-Typen + Funktionen: interface NotificationPref { digestInterval: 'daily'|'weekly'|'off' }. fetchNotificationPref(): GET /modules/tender-radar/notification-pref → NotificationPref. saveNotificationPref(digestInterval): PUT /modules/tender-radar/notification-pref mit Body { digestInterval }. Fehlerbehandlung wie die bestehenden Funktionen (res.ok-Check, aussagekräftige Error-Message).
(b) Erweitere das SavedSearch-Interface um instantAlert: boolean und CreateSavedSearchPayload/UpdateSavedSearchPayload um instantAlert?: boolean, sodass der bestehende createSavedSearch/updateSavedSearch das Feld mitsenden kann. Keine Signatur-Brüche an bestehenden Aufrufern (Feld optional). cd apps/web && npx tsc --noEmit && grep -q "notification-pref" src/lib/tender-radar-api.ts && grep -q "instantAlert" src/lib/tender-radar-api.ts tender-radar-api.ts exportiert fetchNotificationPref/saveNotificationPref und trägt instantAlert im SavedSearch-Typ + den Payloads; tsc fehlerfrei.
Task 3: Frontend UI — Digest-Intervall-Auswahl + Sofort-Alert-Toggle 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 Settings-Page (settings/page.tsx): ergänze unter dem bestehenden SourceConfigForm einen eigenen Abschnitt „Benachrichtigungen" mit einer Digest-Intervall-Auswahl (select oder shadcn-RadioGroup) mit den drei Optionen Täglich / Wöchentlich / Aus. Lade den aktuellen Wert per fetchNotificationPref beim Mount, speichere Änderungen per saveNotificationPref. Hardcodiertes Deutsch (i18n = Phase 14, bestehende Konvention). Da die Page eine Server-Komponente-Shell ist, kapsle die interaktive Auswahl in eine kleine 'use client'-Komponente (analog SourceConfigForm) — z.B. inline oder als settings/components/NotificationPrefForm.tsx; halte es minimal.SavedSearchBar (SavedSearchBar.tsx): ergänze je Profil-Chip einen Sofort-Alert-Toggle (Checkbox/Switch mit aria-label, z.B. „Sofort-Alert für {name}"), der profile.instantAlert widerspiegelt und beim Umschalten updateSavedSearch(profile.id, { instantAlert: next }) aufruft und danach load() erneut ausführt. Fehlerbehandlung im bestehenden error-State. Default-Zustand aus (D-04). Beachte den bestehenden filters-Contract nicht zu brechen — instantAlert ist ein Profil-Feld neben name/filters, kein Filter-Key.
Erweitere SavedSearchBar.test.tsx (bestehendes Vitest+Testing-Library-Muster): Test, dass der Toggle den aktuellen instantAlert-Zustand rendert und ein Klick updateSavedSearch mit { instantAlert: } aufruft (fetch gemockt wie im bestehenden Spec). pnpm --filter web test -- SavedSearchBar && cd apps/web && npx tsc --noEmit Settings-Page hat eine funktionierende Täglich/Wöchentlich/Aus-Auswahl (lädt + speichert Pref); SavedSearchBar hat pro Profil einen Sofort-Alert-Toggle (lädt Zustand, speichert per updateSavedSearch); SavedSearchBar-Spec grün; tsc fehlerfrei.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Browser (Client) → API (Pref/Profil-Routen) | Nutzer-gesteuerte digestInterval-/instantAlert-Werte kreuzen die HTTP-Grenze |
| Auth-Context → Pref/Profil-Scoping | userId/tenantId dürfen nur aus dem Cookie/Auth-Context stammen |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-12-14 | Elevation of Privilege / IDOR | GET/PUT notification-pref, saved-search-Routen | high | mitigate | userId/tenantId ausschließlich aus extractTriageContext(req); DTOs tragen kein userId/tenantId-Feld; Pref-Upsert auf @@unique userId — kein Zugriff auf fremde Zeilen |
| T-12-15 | Tampering | digestInterval-Input | medium | mitigate | @IsIn(['daily','weekly','off']) im DTO (V5) — ungültige Werte abgewiesen, bevor sie die Fälligkeitslogik erreichen |
| T-12-16 | Tampering | instantAlert-Input | low | mitigate | @IsBoolean im DTO; Ownership-Prüfung in TenderSavedSearchService.update (fremdes Profil → NotFound) |
| T-12-17 | Information Disclosure | Profil-Existenz-Leak | low | mitigate | update/remove kollabieren fehlend vs. fremd zu NotFound (bestehendes Phase-11-Muster) |
| T-12-SC | Tampering | npm/pip/cargo installs | high | accept | Keine Paketinstallation (RESEARCH Package Legitimacy Audit) |
| </threat_model> |
<success_criteria>
- Nutzer kann Digest-Intervall Täglich/Wöchentlich/Aus wählen und die Wahl wird persistiert
- Nutzer kann pro Profil Sofort-Alert ein-/ausschalten (Default aus), Wahl wird persistiert
- Alle neuen Routen sind IDOR-sicher per userId aus dem Auth-Context gescoped
- digestInterval wird server-seitig auf die drei erlaubten Werte validiert </success_criteria>