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

165 lines
13 KiB
Markdown

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