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

11 KiB
Raw Blame History

phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, coverage, duration, completed, status
phase plan subsystem tags requires provides affects tech-stack key-files key-decisions patterns-established requirements-completed coverage duration completed status
12-tender-notifications 03 api
nestjs
prisma
tender-radar
notifications
instant-alert
tdd
phase provides
12-01 TenderMatch model (single nullable notifiedAt eligibility gate) + TenderMatchingService.matchDelta
phase provides
12-02 TenderMailService.sendInstant (already implemented, no-throw boolean signal) + TenderDigestScheduler reading the same notifiedAt gate
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)
12-04-tender-notification-settings-ui
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
created modified
apps/api/src/tenders/tender-notifications.integration.spec.ts
apps/api/src/tenders/tender-matching.service.ts
apps/api/src/tenders/tender-matching.service.spec.ts
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
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
NOTIFY-02
NOTIFY-03
id description requirement verification human_judgment
D1 matchDelta dispatches instant alerts only for instantAlert=true profiles (D-04); instantAlert=false profiles never trigger sendInstant NOTIFY-02
kind ref status
unit apps/api/src/tenders/tender-matching.service.spec.ts#Instant-Dispatch nur instantAlert=true (D-04) pass
false
id description requirement verification human_judgment
D2 Multiple freshly-matched tenders of the same profile in one tick bundle into exactly ONE sendInstant call (D-05), never one mail per match NOTIFY-02
kind ref status
unit apps/api/src/tenders/tender-matching.service.spec.ts#Bündelung pro Profil/Tick (D-05) pass
false
id description requirement verification human_judgment
D3 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 NOTIFY-03
kind ref status
unit apps/api/src/tenders/tender-matching.service.spec.ts#Stempelung nach Erfolg (D-06) / Retry-Sicherheit (Robustheit) pass
false
id description requirement verification human_judgment
D4 An instantAlert=true profile with no fresh notifiedAt=NULL matches this tick never calls sendInstant (no spurious mail) NOTIFY-02
kind ref status
unit apps/api/src/tenders/tender-matching.service.spec.ts#nichts Neues löst keinen Alert aus pass
false
id description requirement verification human_judgment
D5 Cross-channel single-email guarantee: instantAlert=true yields sendInstant=1/sendDigest=0; instantAlert=false yields sendInstant=0/sendDigest=1 — never both, never zero NOTIFY-03
kind ref status
integration apps/api/src/tenders/tender-notifications.integration.spec.ts (both scenarios) pass
false
id description verification human_judgment rationale
D6 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
true 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.
~15min 2026-07-22 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.