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

13 KiB

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 02 api
nestjs
nodemailer
cron
tender-radar
notifications
smtp
multi-tenant
phase provides
12-01 TenderMatch model (single nullable notifiedAt eligibility gate) + TenderNotificationPref model + TenderMatchingService.matchDelta
phase provides
07/10 (DKV mail pattern) DkvMailService structural template — fresh nodemailer transport per send via SettingsService.getDecryptedSmtpConfig(tenantId), transport.close() in finally
TenderMailService — sendDigest/sendInstant, DkvMailService-clone tenant-SMTP send path, never throws (false = skipped/failed)
TenderDigestScheduler — ONE global @nestjs/schedule cron (daily 07:00), findMany over ALL due users across ALL tenants
TendersModule now imports SettingsModule and registers both new providers — DI graph resolvable
12-03-tender-instant-alerts
12-04-tender-notification-settings-ui
added patterns
TenderMailService never throws — sendDigest/sendInstant resolve to false on missing SmtpConfig or send failure, letting callers decide notifiedAt stamping with a single boolean instead of try/catch
TenderDigestScheduler.runDigest(now: Date = new Date()) accepts an injectable clock — tests pass a fixed Monday/Tuesday date instead of faking the system clock
Digest due-date resolution: missing TenderNotificationPref row defaults to 'daily' (D-01); weekly is gated on a Europe/Berlin Monday check
created modified
apps/api/src/tenders/tender-mail.service.ts
apps/api/src/tenders/tender-mail.service.spec.ts
apps/api/src/tenders/tender-digest.scheduler.ts
apps/api/src/tenders/tender-digest.scheduler.spec.ts
apps/api/src/tenders/tenders.module.ts
TenderMailService swallows both the 'missing SmtpConfig' AND 'send failure' cases into a single boolean return (never throws) — unlike DkvMailService (which rethrows for its retry orchestrator), because the digest scheduler needs one clean signal to decide whether to stamp notifiedAt, and a cron run must never crash on one tenant's misconfiguration
runDigest(now: Date = new Date()) takes an injectable clock parameter instead of relying on vi.useFakeTimers()/system-clock mocking — makes the Monday-only weekly gate directly testable with fixed dates
estimatedValue is formatted via String() only, never Number() — preserves a Prisma Decimal's own toString(), a plain numeric string, or null verbatim without precision loss or a silent NaN
Digest candidate selection: prisma.tenderMatch.findMany({ where: { notifiedAt: null }, select: { userId: true }, distinct: ['userId'] }) across ALL tenants in one query — the findMany-not-findFirst multi-tenant safety invariant from TenderSchedulerService, now also proven for the digest cron
NOTIFY-01
NOTIFY-04
NOTIFY-03
id description requirement verification human_judgment
D1 TenderMailService.sendDigest/sendInstant — per-send SMTP resolution via getDecryptedSmtpConfig(tenantId), fresh nodemailer transport + close() in finally (even on sendMail failure), no-throw skip on missing SmtpConfig, sectioned digest body with HTML-escaped titles/profile names and no blind Number() coercion of estimatedValue NOTIFY-04
kind ref status
unit apps/api/src/tenders/tender-mail.service.spec.ts (9 tests) pass
false
id description requirement verification human_judgment
D2 TenderDigestScheduler — single global cron registered via SchedulerRegistry; due-date resolution (daily always, weekly Monday-only Europe/Berlin, off never, missing pref = daily default); multi-tenant findMany over all due users; one sectioned mail per user grouped by saved-search profile; notifiedAt/channel stamped only after a successful send; per-user try/catch so one broken user/tenant never aborts the run NOTIFY-01
kind ref status
unit apps/api/src/tenders/tender-digest.scheduler.spec.ts (11 tests) pass
false
id description requirement verification human_judgment
D3 No-double-send invariant: digest selects strictly notifiedAt IS NULL matches; an already-instant-notified match is never re-included or re-stamped by the digest run NOTIFY-03
kind ref status
unit apps/api/src/tenders/tender-digest.scheduler.spec.ts#kein Doppelversand (D-06) pass
false
id description verification human_judgment
D4 TendersModule imports SettingsModule and registers TenderMailService + TenderDigestScheduler; DI graph resolvable
kind ref status
unit npx tsc --noEmit -p tsconfig.json (clean) + grep SettingsModule/TenderDigestScheduler in tenders.module.ts pass
false
id description verification human_judgment rationale
D5 Actual SMTP transport/delivery against a real mailbox (Mailhog) and true cross-tenant digest isolation with two real users/tenants
true Real mail delivery cannot be observed with a mocked nodemailer transport; needs a live SMTP relay (localhost:1025) and two real tenant SmtpConfig rows — deferred to the phase-end UAT pass per 12-VALIDATION.md Manual-Only Verifications.
~8min 2026-07-22 complete

Phase 12 Plan 02: Digest-Kanal + Mandanten-SMTP-Versand Summary

TenderMailService (DkvMailService-Klon: frischer nodemailer-Transport pro Send über mandanten-SMTP, transport.close() im finally, nie Throw) plus ein einziger globaler TenderDigestScheduler-Cron, der über findMany alle fälligen Nutzer aller Mandanten bedient, je Nutzer eine nach Suchprofil sektionierte Mail sendet und Matches erst nach erfolgreichem Versand als benachrichtigt stempelt.

Performance

  • Duration: ~8 min
  • Started: 2026-07-22T09:07:30Z
  • Completed: 2026-07-22T09:14:40Z
  • Tasks: 3 completed (Tasks 1 and 2 followed RED→GREEN TDD)
  • Files modified: 5 (4 created, 1 modified)

Accomplishments

  • Implemented TenderMailService as a structural clone of DkvMailService: sendDigest(user, tenantId, sections) builds ONE mail sectioned by saved-search profile name (D-02); sendInstant(user, tenantId, search, tenders) builds ONE collective mail for a single profile (D-05). Both resolve the tenant's decrypted SMTP config via settingsService.getDecryptedSmtpConfig(tenantId), build a fresh nodemailer transport per send (never cached), and always transport.close() in finally (WR-01 socket-leak guard) — even when sendMail rejects.
  • Unlike DkvMailService, TenderMailService never throws: a missing SmtpConfig and a send failure both resolve to false, giving callers a single boolean signal for whether TenderMatch.notifiedAt may be stamped.
  • estimatedValue is rendered via String() only — never Number() — avoiding precision loss/NaN on the mostly-null Decimal field; tender titles and saved-search profile names are HTML-escaped before interpolation into the mail's HTML body (T-12-08 email-injection guard).
  • Implemented TenderDigestScheduler: a SINGLE platform-wide @nestjs/schedule cron (0 7 * * *), registered via SchedulerRegistry exactly like TenderSchedulerService (not the single-tenant DkvSchedulerService pattern). runDigest() selects candidate users as distinct userIds with an open (notifiedAt: null) TenderMatch across all tenants via findMany (never findFirst), resolves each user's TenderNotificationPref.digestInterval (missing row → 'daily' default per D-01), and — for due users only — groups their un-notified matches by saved-search profile name into one sendDigest call per user. notifiedAt+notifiedChannel='digest' are stamped only after a successful send.
  • Robustness: each candidate user is processed inside its own try/catch — a missing SMTP config, a failed send, or an unexpected thrown error (e.g. a broken pref lookup) for one user/tenant leaves that user's matches notifiedAt=NULL (retried next run) and never aborts the run for the remaining users.
  • Registered TenderMailService and TenderDigestScheduler as providers in TendersModule, and imported SettingsModule so TenderMailService can inject SettingsService. Full apps/api suite green: 192/192 across 17 files; npx tsc --noEmit clean.

Task Commits

Each task was committed atomically:

  1. Task 1: TenderMailService — mandanten-SMTP-Versand (DkvMailService-Klon) — fbcc108 (test, RED) → 53e72df (feat, GREEN)
  2. Task 2: TenderDigestScheduler — ein globaler Cron, findMany über fällige Nutzer — 2f30584 (test, RED) → c0a8906 (feat, GREEN)
  3. Task 3: Provider-Registrierung + SettingsModule-Import — 3244167 (feat)

Plan metadata: commit pending (this SUMMARY + STATE/ROADMAP update)

Files Created/Modified

  • apps/api/src/tenders/tender-mail.service.ts - TenderMailService: sendDigest/sendInstant, resolveTransport (fresh nodemailer transport per tenant SMTP config), text/HTML body builders, escapeHtml, formatDeadline, formatEstimatedValue
  • apps/api/src/tenders/tender-mail.service.spec.ts - 9 unit tests: SMTP resolution (secure/requireTLS per encryption mode, auth-when-username), fresh-transport+close (incl. on sendMail failure), no-throw skip on missing config, sectioned digest body, HTML-escaping
  • apps/api/src/tenders/tender-digest.scheduler.ts - TenderDigestScheduler: onModuleInit cron registration (tender-digest, 0 7 * * *), runDigest(now) core fan-out, groupMatchesByProfile, isMondayInBerlin
  • apps/api/src/tenders/tender-digest.scheduler.spec.ts - 11 unit tests: due-date resolution (daily/weekly/off/default-daily), multi-tenant findMany (two tenants both served, source has no findFirst), grouped-sections single mail, no-double-send stamping, per-user robustness (skip + thrown-error cases), cron registration
  • apps/api/src/tenders/tenders.module.ts - Imports SettingsModule; registers TenderMailService + TenderDigestScheduler as providers alongside the existing TenderMatchingService

Decisions Made

  • TenderMailService never throws (unlike DkvMailService) — both the missing-config and send-failure paths resolve to false. This keeps the digest scheduler's success/skip decision to a single boolean check instead of a nested try/catch, and structurally guarantees a cron tick cannot crash on one tenant's broken SMTP.
  • runDigest takes an injectable now: Date = new Date() parameter rather than mocking the system clock in tests — the weekly-Monday-only gate is asserted directly with fixed dates (2026-07-27 verified Monday, 2026-07-28 verified Tuesday, both in Europe/Berlin).
  • estimatedValue formatting uses String() exclusively, never Number() — matches the plan's explicit instruction and preserves a Prisma Decimal's own string representation without precision loss.

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] Removed literal findFirst from source-file comments to satisfy the scheduler's own regression test

  • Found during: Task 2 verification (pnpm --filter api test -- tender-digest.scheduler)
  • Issue: The scheduler's own spec asserts (via readFileSync on the source) that the word findFirst never appears anywhere in tender-digest.scheduler.ts — including comments — as the Pitfall-1 regression guard. My initial doc comments referenced DkvSchedulerService's "v1 single-tenant, findFirst()" pattern verbatim, which the source-scan test correctly flagged.
  • Fix: Reworded the two occurrences to describe the same pattern without using the literal token (e.g. "v1 single-tenant, find-first-row pattern"; "never a first-row-only lookup").
  • Files modified: apps/api/src/tenders/tender-digest.scheduler.ts
  • Verification: pnpm --filter api test -- tender-digest.scheduler green (11/11), including the source-scan assertion.
  • Committed in: c0a8906 (Task 2 GREEN commit)

Total deviations: 1 auto-fixed (1 bug — comment wording, no logic change) Impact on plan: No scope creep. All three tasks executed exactly as specified otherwise.

Issues Encountered

None beyond the comment-wording fix above.

User Setup Required

None - no external service configuration required. TenderMailService/TenderDigestScheduler reuse the existing per-tenant SmtpConfig rows configured via Settings (Phase 7); no new environment variables or dashboards.

Next Phase Readiness

  • TenderMailService.sendInstant is already implemented (per the plan's explicit instruction) so Plan 12-03 (instant alerts) only needs to wire it into TenderMatchingService's poll-tick dispatch — no new mail-sending logic required.
  • TenderDigestScheduler is live and registered; once real per-tenant SmtpConfig rows and TenderNotificationPref rows exist, the daily 07:00 cron will pick up any un-notified TenderMatch rows automatically.
  • Manual/UAT verification (real Mailhog send, two-tenant isolation with live SmtpConfig rows) remains open per 12-VALIDATION.md — deferred to the phase-end UAT pass, consistent with Plan 12-01's precedent.
  • No blockers. pnpm --filter api test / npx vitest run (full apps/api suite) green: 192/192 across 17 files. npx tsc --noEmit clean.

Phase: 12-tender-notifications Completed: 2026-07-22

Self-Check: PASSED

All created/modified files verified present on disk; all 5 task commit hashes (fbcc108, 53e72df, 2f30584, c0a8906, 3244167) verified present in git log.