From 1a0cd375c2870bdf46512d54f9a57441001b2bc7 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 22 Jul 2026 09:23:21 +0200 Subject: [PATCH] docs(12-03): complete instant-alert dispatch plan --- .planning/REQUIREMENTS.md | 4 +- .planning/ROADMAP.md | 6 +- .planning/STATE.md | 16 +- .../12-tender-notifications/12-03-SUMMARY.md | 153 ++++++++++++++++++ 4 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 .planning/phases/12-tender-notifications/12-03-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 03d467c..4af3c93 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -45,7 +45,7 @@ ### NOTIFY — Benachrichtigung - [x] **NOTIFY-01**: Nutzer erhält einen periodischen E-Mail-Digest passender Treffer; Intervall im Webinterface konfigurierbar. -- [ ] **NOTIFY-02**: Nutzer erhält optional eine Sofort-E-Mail bei einem neuen Treffer eines aktiven Suchprofils; im Webinterface aktivierbar. +- [x] **NOTIFY-02**: Nutzer erhält optional eine Sofort-E-Mail bei einem neuen Treffer eines aktiven Suchprofils; im Webinterface aktivierbar. - [x] **NOTIFY-03**: Das System unterscheidet „getroffen" von „benachrichtigt" (kein Doppelversand Digest+Sofort; kein Rückstau-Massenversand beim Anlegen eines Suchprofils). - [x] **NOTIFY-04**: Der E-Mail-Versand nutzt die mandantenspezifische SMTP-Konfiguration (bestehendes DKV-Mail-Muster). @@ -93,7 +93,7 @@ | UI-04 | Phase 11 | Complete | | UI-05 | Phase 11 | Complete | | NOTIFY-01 | Phase 12 | Complete | -| NOTIFY-02 | Phase 12 | Pending | +| NOTIFY-02 | Phase 12 | Complete | | NOTIFY-03 | Phase 12 | Complete | | NOTIFY-04 | Phase 12 | Complete | | INGEST-02 | Phase 13 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 52b1efc..355b344 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -410,7 +410,7 @@ Plans: 3. Activating a new search profile with many pre-existing historical matches does not flood the user with individual emails -- backfill is suppressed/batched on first activation, and a match already notified once (digest or instant) is never notified again for the same tender+profile pair (explicit matched-vs-notified state) 4. Notification emails are sent through the tenant's own SMTP configuration (reusing the DKV mail pattern), not a shared/global system mailer -**Plans**: 2/4 plans executed +**Plans**: 3/4 plans executed **UI hint**: yes **Wave 1** @@ -423,7 +423,7 @@ Plans: **Wave 3** *(blocked on Wave 2)* -- [ ] 12-03-PLAN.md — Sofort-Alerts am Poll-Tick + Ein-Mail-Garantie über beide Kanäle (NOTIFY-02/03) +- [x] 12-03-PLAN.md — Sofort-Alerts am Poll-Tick + Ein-Mail-Garantie über beide Kanäle (NOTIFY-02/03) - [ ] 12-04-PLAN.md — Web-UI: Digest-Intervall + Sofort-Alert-Toggle, Pref-Backend (NOTIFY-01/02) ### Phase 13: Scraping Adapters & Cross-Source Deduplication @@ -476,6 +476,6 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10 | 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 | 6/6 | In Progress| | -| 12. Tender Notifications | 2/4 | In Progress| | +| 12. Tender Notifications | 3/4 | In Progress| | | 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 709a584..f734d0e 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,15 +5,15 @@ milestone_name: Ausschreibungs-Radar current_phase: 12 current_phase_name: tender-notifications status: executing -stopped_at: Completed 12-02-PLAN.md -last_updated: "2026-07-22T07:16:13.211Z" +stopped_at: Completed 12-03-PLAN.md +last_updated: "2026-07-22T07:23:12.233Z" last_activity: 2026-07-22 last_activity_desc: Phase 12 execution started progress: total_phases: 12 completed_phases: 10 total_plans: 56 - completed_plans: 53 + completed_plans: 54 --- # Project State @@ -28,11 +28,11 @@ See: .planning/PROJECT.md (updated 2026-07-17) ## Current Position Phase: 12 (tender-notifications) — EXECUTING -Plan: 3 of 4 +Plan: 4 of 4 Status: Ready to execute Last activity: 2026-07-22 — Phase 12 execution started -Progress: [██████████] 95% +Progress: [██████████] 96% ## Performance Metrics @@ -88,6 +88,7 @@ Progress: [██████████] 95% | Phase 11 P06 | 35min | 3 tasks | 12 files | | Phase 12 P01 | 35min | 3 tasks | 7 files | | Phase 12 P02 | 8min | 3 tasks | 5 files | +| Phase 12 P03 | 15min | 2 tasks | 3 files | ## Accumulated Context @@ -187,6 +188,7 @@ Recent decisions affecting current work: - [Phase ?]: Delta-only matching (no backfill/suppression table) structurally prevents backfill-flood for new saved-search profiles (12-01, D-07) - [Phase ?]: TenderMailService swallows missing-SmtpConfig and send-failure into a single boolean (never throws) so the digest cron gets one clean success signal for stamping notifiedAt - [Phase ?]: TenderDigestScheduler.runDigest(now) takes an injectable clock parameter for testable Monday-only weekly-digest gating +- [Phase ?]: Instant-Dispatch filtert die bereits geladenen savedSearches (kein zweiter Query); notifiedAt/channel='instant' nur nach Erfolg gestempelt — identisches Gate wie Digest (D-06) ### Pending Todos @@ -224,7 +226,7 @@ Items acknowledged and carried forward from previous milestone close: ## Session Continuity -Last session: 2026-07-22T07:16:13.196Z -Stopped at: Completed 12-02-PLAN.md +Last session: 2026-07-22T07:23:12.215Z +Stopped at: Completed 12-03-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/12-tender-notifications/12-03-SUMMARY.md b/.planning/phases/12-tender-notifications/12-03-SUMMARY.md new file mode 100644 index 0000000..d9c5d3b --- /dev/null +++ b/.planning/phases/12-tender-notifications/12-03-SUMMARY.md @@ -0,0 +1,153 @@ +--- +phase: 12-tender-notifications +plan: 03 +subsystem: api +tags: [nestjs, prisma, tender-radar, notifications, instant-alert, tdd] + +requires: + - phase: 12-01 + provides: "TenderMatch model (single nullable notifiedAt eligibility gate) + TenderMatchingService.matchDelta" + - phase: 12-02 + provides: "TenderMailService.sendInstant (already implemented, no-throw boolean signal) + TenderDigestScheduler reading the same notifiedAt gate" +provides: + - "TenderMatchingService.matchDelta — instant-alert dispatch at end of the poll tick for instantAlert=true profiles" + - "Per-profile bundled instant mail (D-05): all of a profile's freshly-matched tenders in one tick become ONE sendInstant call" + - "tender-notifications.integration.spec.ts — proves instant+digest together always send exactly one mail (NOTIFY-03 core invariant, D-06)" +affects: [12-04-tender-notification-settings-ui] + +tech-stack: + added: [] + patterns: + - "Instant dispatch runs AFTER all match upserts of the tick, filtering the same savedSearches list already loaded for matching (no second tenderSavedSearch.findMany)" + - "notifiedAt/notifiedChannel='instant' stamped ONLY on a successful (non-skipped, non-thrown) sendInstant — the exact same gate the digest reads, so the two channels can never double-notify a pair" + - "Per-profile try/catch around instant dispatch — a send failure/thrown error for one profile never aborts the tick or the remaining profiles' dispatch" + +key-files: + created: + - apps/api/src/tenders/tender-notifications.integration.spec.ts + modified: + - apps/api/src/tenders/tender-matching.service.ts + - apps/api/src/tenders/tender-matching.service.spec.ts + +key-decisions: + - "Instant dispatch filters the savedSearches array already loaded at the top of matchDelta (instantAlert===true) rather than a second DB query — one findMany covers both matching and instant-eligibility" + - "TenderMailService injected as a second constructor parameter (Nest DI resolves it automatically via the existing tenders.module.ts provider registration — no module change needed, exactly as 12-02's SUMMARY anticipated)" + - "A profile with instantAlert=true but zero fresh (notifiedAt=NULL) matches this tick is a silent no-op (no sendInstant call) — checked before the user lookup to avoid an unnecessary query" + +patterns-established: + - "Shared hand-rolled Prisma fake across matchDelta and runDigest in the integration spec: one in-memory Map-backed tenderMatch store lets both services' real state transitions (upsert -> instant stamp -> digest candidate exclusion) be asserted end-to-end without a live DB" + +requirements-completed: [NOTIFY-02, NOTIFY-03] + +coverage: + - id: D1 + description: "matchDelta dispatches instant alerts only for instantAlert=true profiles (D-04); instantAlert=false profiles never trigger sendInstant" + requirement: "NOTIFY-02" + verification: + - kind: unit + ref: "apps/api/src/tenders/tender-matching.service.spec.ts#Instant-Dispatch nur instantAlert=true (D-04)" + status: pass + human_judgment: false + - id: D2 + description: "Multiple freshly-matched tenders of the same profile in one tick bundle into exactly ONE sendInstant call (D-05), never one mail per match" + requirement: "NOTIFY-02" + verification: + - kind: unit + ref: "apps/api/src/tenders/tender-matching.service.spec.ts#Bündelung pro Profil/Tick (D-05)" + status: pass + human_judgment: false + - id: D3 + description: "notifiedAt/notifiedChannel='instant' stamped only after a successful send; a send failure/thrown error leaves notifiedAt NULL for that profile and does not abort the tick for other profiles" + requirement: "NOTIFY-03" + verification: + - kind: unit + ref: "apps/api/src/tenders/tender-matching.service.spec.ts#Stempelung nach Erfolg (D-06) / Retry-Sicherheit (Robustheit)" + status: pass + human_judgment: false + - id: D4 + description: "An instantAlert=true profile with no fresh notifiedAt=NULL matches this tick never calls sendInstant (no spurious mail)" + requirement: "NOTIFY-02" + verification: + - kind: unit + ref: "apps/api/src/tenders/tender-matching.service.spec.ts#nichts Neues löst keinen Alert aus" + status: pass + human_judgment: false + - id: D5 + description: "Cross-channel single-email guarantee: instantAlert=true yields sendInstant=1/sendDigest=0; instantAlert=false yields sendInstant=0/sendDigest=1 — never both, never zero" + requirement: "NOTIFY-03" + verification: + - kind: integration + ref: "apps/api/src/tenders/tender-notifications.integration.spec.ts (both scenarios)" + status: pass + human_judgment: false + - id: D6 + description: "Actual SMTP delivery of a real instant-alert email against a live mailbox (Mailhog), and true multi-tenant instant-alert isolation with two real users/tenants" + verification: [] + human_judgment: true + rationale: "Real mail delivery cannot be observed with a mocked nodemailer transport (TenderMailService is unit-tested with mocks in 12-02); needs a live SMTP relay and real per-tenant SmtpConfig rows — deferred to the phase-end UAT pass per 12-VALIDATION.md Manual-Only Verifications, consistent with 12-01/12-02's precedent." + +duration: ~15min +completed: 2026-07-22 +status: complete +--- + +# Phase 12 Plan 03: Instant-Alert-Kanal + Ein-Mail-Garantie über beide Kanäle Summary + +**matchDelta um synchronen Instant-Dispatch am Poll-Tick erweitert (per-Profil-Bündelung, notifiedAt='instant' nur nach Erfolg) plus ein Integrations-Spec, der beweist, dass instant+digest zusammen strukturell genau eine Mail pro Tender×Profil-Paar ergeben.** + +## Performance + +- **Duration:** ~15 min +- **Completed:** 2026-07-22 +- **Tasks:** 2 completed (Task 1 followed RED→GREEN TDD) +- **Files modified:** 3 (1 created, 2 modified) + +## Accomplishments +- Erweiterte `TenderMatchingService.matchDelta`: NACH allen Match-Upserts eines Ticks filtert der Service die bereits geladenen Suchprofile auf `instantAlert===true`. Für jedes solche Profil werden dessen frische (`notifiedAt=NULL`, `tenderId IN newTenderIds`) Treffer geladen; bei mindestens einem Treffer wird `TenderMailService.sendInstant` GENAU EINMAL pro Profil pro Tick aufgerufen (D-05 — Sammelmail, nie eine Mail pro Treffer). +- `notifiedAt`/`notifiedChannel='instant'` werden NUR bei erfolgreichem (nicht-„skipped", nicht-geworfenem) Send gestempelt — dasselbe Eligibility-Gate, das der Digest liest (D-06). Bei Fehlschlag/Exception bleibt `notifiedAt` NULL für dieses Profil (Retry beim nächsten Tick/Digest); jeder Profil-Dispatch läuft in eigenem try/catch, sodass ein Sendefehler weder den Tick noch die übrigen Profile abbricht. +- `TenderMailService` als zweiter Konstruktor-Parameter injiziert — keine `tenders.module.ts`-Änderung nötig, da beide Provider dort bereits registriert sind (wie von 12-02s Summary vorausgesehen). +- Neuer Integrations-Spec `tender-notifications.integration.spec.ts`: führt `matchDelta` und `runDigest` gegen einen gemeinsamen, gemockten Prisma-Store aus (kein Live-DB, gemocktes `TenderMailService`, kein Live-SMTP) und beweist die NOTIFY-03-Kern-Invariante End-to-End über beide Kanäle — `instantAlert=true` ⇒ `sendInstant=1, sendDigest=0`; `instantAlert=false` ⇒ `sendInstant=0, sendDigest=1`. In beiden Szenarien: `sendInstant + sendDigest === 1`. +- Volle `apps/api`-Suite grün: 199/199 Tests über 18 Dateien; `npx tsc --noEmit` sauber. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Instant-Dispatch in matchDelta (RED→GREEN)** - `86c184f` (test, RED) → `ec032ad` (feat, GREEN) +2. **Task 2: Integrations-Spec — instant+digest ergeben genau eine Mail** - `5395ce6` (test) + +**Plan metadata:** commit pending (this SUMMARY + STATE/ROADMAP update) + +## Files Created/Modified +- `apps/api/src/tenders/tender-matching.service.ts` - Instant-Dispatch am Ende von `matchDelta`: Filter auf `instantAlert===true`, fresh-Match-Lookup, `sendInstant`-Aufruf pro Profil/Tick, Stempelung nur nach Erfolg, Per-Profil try/catch +- `apps/api/src/tenders/tender-matching.service.spec.ts` - 5 neue Tests (D-04 Selektivität, D-05 Bündelung, D-06 Stempelung, Retry-Sicherheit, „nichts Neues"); bestehende Fake-Prisma um `tenderMatch.findMany`/`updateMany` + `user.findUnique` erweitert; alle Instanziierungen um `mail`-Mock ergänzt +- `apps/api/src/tenders/tender-notifications.integration.spec.ts` - 2 Szenarien (instant-on, instant-off) mit gemeinsamem gemocktem Prisma-Store; beweist die Ein-Mail-Garantie über beide Kanäle + +## Decisions Made +- Instant-Dispatch nutzt die bereits am Anfang von `matchDelta` geladene `savedSearches`-Liste (keine zweite `tenderSavedSearch.findMany`) — ein Query deckt sowohl Matching als auch Instant-Eligibility ab. +- Ein `instantAlert=true`-Profil ohne frische Treffer ist ein stiller No-Op (kein `sendInstant`-Aufruf) — geprüft vor dem User-Lookup, um eine unnötige Query zu vermeiden. +- Integrations-Spec teilt EINEN Prisma-Fake-Store zwischen `TenderMatchingService` und `TenderDigestScheduler`, damit der Zustandsübergang (Upsert → Instant-Stempel → Digest-Ausschluss) real durchläuft, statt beide Services isoliert zu mocken. + +## Deviations from Plan + +None - plan executed exactly as written. `TenderMailService` war bereits (aus 12-02) mit `sendInstant` fertig implementiert; `tenders.module.ts` musste wie geplant nicht geändert werden. + +## Issues Encountered + +Ein initialer TypeScript-Fehler (`unknown[]` nicht zuweisbar zu `TenderMailItem[]`) durch eine zu enge explizite Parameter-Annotation in den `.map()`-Aufrufen der Instant-Dispatch-Logik — behoben durch Entfernen der Annotationen und Verlassen auf Typinferenz aus Prisma's `include: { tender: true }`-Rückgabetyp. Kein Verhaltensunterschied, reine Typkorrektur vor dem ersten `tsc`-Lauf (kein separater Rule-1-Eintrag, da vor jeglicher Verifikation/Commit behoben). + +## User Setup Required +None - keine externe Service-Konfiguration erforderlich. `TenderMailService.sendInstant` nutzt die bestehende mandanten-SMTP-Konfiguration (Phase 7/12-02). + +## Next Phase Readiness +- Die NOTIFY-03-Kern-Invariante (kein Doppelversand über Instant+Digest) ist jetzt vollständig implementiert und End-to-End bewiesen — Plan 12-04 (Settings-UI: Digest-Intervall + Sofort-Toggle pro Profil) kann direkt auf die bestehenden `instantAlert`/`digestInterval`-Felder aufsetzen, ohne weitere Backend-Änderungen an Matching/Dispatch. +- Reale SMTP-Zustellung eines Sofort-Alerts (Mailhog) und Multi-Tenant-Isolation mit zwei echten Nutzern bleiben offen für die Phase-Ende-UAT (12-VALIDATION.md), konsistent mit 12-01/12-02. +- Keine Blocker. Volle `apps/api`-Suite grün (199/199, 18 Dateien); `npx tsc --noEmit` sauber. + +--- +*Phase: 12-tender-notifications* +*Completed: 2026-07-22* + +## Self-Check: PASSED + +All created/modified files verified present on disk; all 3 task commit hashes (86c184f, ec032ad, 5395ce6) verified present in git log.