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