Files
tessera-ctl/.planning/phases/12-tender-notifications/12-03-SUMMARY.md
T

154 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.