--- phase: 12-tender-notifications plan: 02 subsystem: api tags: [nestjs, nodemailer, cron, tender-radar, notifications, smtp, multi-tenant] requires: - phase: 12-01 provides: "TenderMatch model (single nullable notifiedAt eligibility gate) + TenderNotificationPref model + TenderMatchingService.matchDelta" - phase: 07/10 (DKV mail pattern) provides: "DkvMailService structural template — fresh nodemailer transport per send via SettingsService.getDecryptedSmtpConfig(tenantId), transport.close() in finally" provides: - "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" affects: [12-03-tender-instant-alerts, 12-04-tender-notification-settings-ui] tech-stack: 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" key-files: created: - 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 modified: - apps/api/src/tenders/tenders.module.ts key-decisions: - "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" patterns-established: - "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" requirements-completed: [NOTIFY-01, NOTIFY-04, NOTIFY-03] coverage: - id: D1 description: "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" requirement: "NOTIFY-04" verification: - kind: unit ref: "apps/api/src/tenders/tender-mail.service.spec.ts (9 tests)" status: pass human_judgment: false - id: D2 description: "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" requirement: "NOTIFY-01" verification: - kind: unit ref: "apps/api/src/tenders/tender-digest.scheduler.spec.ts (11 tests)" status: pass human_judgment: false - id: D3 description: "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" requirement: "NOTIFY-03" verification: - kind: unit ref: "apps/api/src/tenders/tender-digest.scheduler.spec.ts#kein Doppelversand (D-06)" status: pass human_judgment: false - id: D4 description: "TendersModule imports SettingsModule and registers TenderMailService + TenderDigestScheduler; DI graph resolvable" verification: - kind: unit ref: "npx tsc --noEmit -p tsconfig.json (clean) + grep SettingsModule/TenderDigestScheduler in tenders.module.ts" status: pass human_judgment: false - id: D5 description: "Actual SMTP transport/delivery against a real mailbox (Mailhog) and true cross-tenant digest isolation with two real users/tenants" verification: [] human_judgment: true rationale: "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." duration: ~8min completed: 2026-07-22 status: 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 `userId`s 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.