--- 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.