Files

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
12-tender-notifications 02 execute 2
12-01
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
true
NOTIFY-01
NOTIFY-04
NOTIFY-03
truths artifacts key_links
Ein Nutzer mit digestInterval=daily (oder ohne Pref-Zeile — Default daily, D-01) und mindestens einem notifiedAt=NULL-Match erhält beim Digest-Lauf genau eine E-Mail, nach Suchprofil gegliedert (D-02)
Zwei Nutzer in zwei Mandanten mit daily erhalten je eine eigene E-Mail über ihre jeweilige mandanten-SMTP-Konfiguration (findMany, kein findFirst)
Nach erfolgreichem Digest-Versand tragen alle einbezogenen Matches notifiedAt=now und notifiedChannel='digest' — beim nächsten Lauf werden sie nicht erneut versendet (D-06)
Ein Mandant ohne SmtpConfig führt zu Skip+Log für dessen Nutzer, notifiedAt bleibt NULL, der Lauf bricht für andere Nutzer NICHT ab
apps/api/src/tenders/tender-mail.service.ts (sendDigest/sendInstant, DkvMailService-Klon)
apps/api/src/tenders/tender-digest.scheduler.ts (ein globaler Cron)
apps/api/src/tenders/tender-mail.service.spec.ts
apps/api/src/tenders/tender-digest.scheduler.spec.ts
TenderMailService ruft SettingsService.getDecryptedSmtpConfig(tenantId) je Send und erzeugt einen frischen nodemailer-Transport, transport.close() im finally (D-08)
Der Digest-Cron selektiert fällige Nutzer via findMany und je Nutzer TenderMatch WHERE userId=? AND notifiedAt IS NULL
TendersModule importiert SettingsModule, damit TenderMailService SettingsService injizieren kann
Der Zustell-Kanal „periodischer Digest" plus der mandanten-SMTP-Versandpfad. Baut TenderMailService (1:1-Klon des DkvMailService-Musters: frischer nodemailer-Transport pro Send über die mandantenspezifische SmtpConfig, transport.close() im finally) und einen EINZIGEN globalen Digest-Cron, der über alle fälligen Nutzer aller Mandanten iteriert, je Nutzer die un-benachrichtigten Matches nach Suchprofil gruppiert, genau eine sektionierte Mail sendet und die Matches als benachrichtigt stempelt.

Purpose: NOTIFY-01 (konfigurierbarer Digest) + NOTIFY-04 (mandanten-SMTP) + der Digest-Hälfte von NOTIFY-03 (kein Doppelversand durch das notifiedAt-IS-NULL-Gate). Nach diesem Plan bekommt ein Nutzer proaktiv E-Mails über neue Treffer.

Output: TenderMailService, TenderDigestScheduler (global), registrierte Provider, SettingsModule-Import.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/phases/12-tender-notifications/12-CONTEXT.md @.planning/phases/12-tender-notifications/12-RESEARCH.md @apps/api/src/dkv/dkv-mail.service.ts @apps/api/src/settings/settings.service.ts @apps/api/src/settings/settings.module.ts @apps/api/src/tenders/tender-scheduler.service.ts @apps/api/src/tenders/tenders.module.ts @apps/api/prisma/schema.prisma Task 1: TenderMailService — mandanten-SMTP-Versand (DkvMailService-Klon) apps/api/src/tenders/tender-mail.service.ts, apps/api/src/tenders/tender-mail.service.spec.ts - Test 1 (D-08 SMTP-Auflösung): sendDigest ruft settingsService.getDecryptedSmtpConfig(tenantId) mit exakt der übergebenen tenantId; der nodemailer-Transport wird mit host/port/secure(ssl-tls)/requireTLS(starttls)/auth aus dieser Config gebaut. - Test 2 (frischer Transport + close): nodemailer.createTransport wird pro Send genau einmal aufgerufen; transport.close() wird im finally aufgerufen (auch bei sendMail-Fehler) — WR-01 Socket-Leck-Schutz. - Test 3 (fehlende Config): getDecryptedSmtpConfig=null → sendDigest/sendInstant sendet nichts, wirft NICHT, gibt einen falsy/„skipped"-Indikator zurück (Aufrufer setzt dann notifiedAt nicht). - Test 4 (Betreff/Body): sendDigest baut EINE Mail an user.email, sektioniert nach Profilname; sendInstant baut EINE Mail für ein Profil mit seiner Tender-Liste. estimatedValue (String, meist null) wird nie ungeprüft Number()-coerct. Schreibe ZUERST tender-mail.service.spec.ts (RED) — nodemailer wird gemockt wie im DkvMailService-Vorbild (vi.mock('nodemailer') mit createTransport → { sendMail, close }). Dann implementiere tender-mail.service.ts (GREEN) als strukturellen Klon von dkv-mail.service.ts.

TenderMailService (@Injectable) mit constructor(settingsService: SettingsService). Private Helper buildTransport-über-tenantId, der getDecryptedSmtpConfig(tenantId) lädt; bei null → generisches Log + return „skipped" (Signal an den Aufrufer, notifiedAt NICHT zu setzen → Retry beim nächsten Lauf, RESEARCH Pitfall 6). Transport exakt nach DkvMailService bauen: secure = encryption==='ssl-tls', requireTLS = encryption==='starttls', auth nur wenn username gesetzt, pass = decryptedPassword ?? ''. sendMail im try, transport.close() im finally.

Öffentliche Methoden:

  • sendDigest(user: { email; ... }, tenantId, sections): sections ist die nach Profil gruppierte Trefferstruktur (Profilname → Tender[]). Baut EINE Mail (D-02): Betreff z.B. „Ausschreibungs-Radar: neue Treffer", Body je Profil eine Überschrift + Liste (Titel, buyerName, deadlineAt, estimatedValue nur wenn nicht null, sourceUrl-Link). Schlichtes hardcodiertes Deutsch, Text + optional HTML (i18n = Phase 14).
  • sendInstant(search: { name; ... }, tenders: Tender[]): EINE Sammel-Mail für ein Profil (D-05) mit derselben Zeilenformatierung. (Von 12-03 aufgerufen; hier bereits implementieren, damit 12-03 nur noch verdrahtet.)

Security: entschlüsseltes Passwort nur im Method-Scope, nie loggen (T-07-10-Muster); generische Fehlermeldungen. Body als escaped/Text — Tender-Titel/Profilname nie ungeprüft in HTML interpolieren (E-Mail-Injection-Schutz). pnpm --filter api test -- tender-mail.service tender-mail.service.spec.ts grün: getDecryptedSmtpConfig je Send, frischer Transport + close() im finally, Skip bei fehlender Config ohne Throw, sektionierter Digest-Body; kein globaler Mailer verwendet.

Task 2: TenderDigestScheduler — ein globaler Cron, findMany über fällige Nutzer apps/api/src/tenders/tender-digest.scheduler.ts, apps/api/src/tenders/tender-digest.scheduler.spec.ts - Test 1 (Fälligkeit D-01): daily-Nutzer sind an jedem Lauf fällig; weekly-Nutzer nur an einem festen Wochentag (Montag Europe/Berlin); off-Nutzer nie. Ein Nutzer OHNE Pref-Zeile wird wie daily behandelt (Default D-01). - Test 2 (Multi-Tenant, findMany): Zwei Nutzer in zwei verschiedenen Mandanten, beide daily, beide mit un-benachrichtigten Matches → beide erhalten je einen sendDigest-Aufruf mit ihrer jeweiligen tenantId. Es wird NIE nur der erste Nutzer bedient. - Test 3 (Gruppierung + eine Mail, D-02): Ein Nutzer mit Matches aus 2 Profilen → genau ein sendDigest-Aufruf, dessen sections beide Profil-Abschnitte enthält. - Test 4 (kein Doppelversand, D-06): Nur Matches mit notifiedAt=NULL werden selektiert; nach erfolgreichem Send updateMany notifiedAt=now, notifiedChannel='digest' auf genau diese Match-IDs. Ein bereits per instant benachrichtigter Match (notifiedAt gesetzt) wird nie einbezogen. - Test 5 (Robustheit): sendDigest-Fehler / fehlende SMTP für einen Nutzer → dessen Matches bleiben notifiedAt=NULL, der Lauf fährt mit den übrigen Nutzern fort (kein Cron-Crash). Schreibe ZUERST tender-digest.scheduler.spec.ts (RED), dann implementiere tender-digest.scheduler.ts (GREEN). Registrierungs- und CronJob-Mechanik verbatim aus tender-scheduler.service.ts übernehmen (require('cron').CronJob-Workaround, SchedulerRegistry.addCronJob, JOB_NAME='tender-digest', OnModuleInit).

TenderDigestScheduler (@Injectable, implements OnModuleInit) mit constructor(schedulerRegistry: SchedulerRegistry, prisma: PrismaService, mail: TenderMailService). onModuleInit registriert EINEN globalen Cron (z.B. '0 7 * * *', täglich 07:00 — ein einziger platform-weiter Job, KEINE Tenant-Dimension, exakt das TenderSchedulerService-Muster, NICHT das DkvSchedulerService-Einzelmandanten-Muster).

Kern-Methode runDigest(): Ermittle die Kandidaten-Nutzer als distinct userId aus prisma.tenderMatch.findMany/groupBy where { notifiedAt: null } — nur Nutzer mit offenen Treffern. Lade je Kandidat dessen Pref via prisma.tenderNotificationPref.findUnique({ where: { userId } }); fehlt die Zeile → als 'daily' behandeln (Default D-01). Bestimme Fälligkeit: daily=immer, weekly=nur wenn Europe/Berlin-Wochentag Montag, off=skip. WICHTIG: über ALLE fälligen Nutzer iterieren (findMany-Semantik) — den Einzelmandanten-Einstieg (findFirst) NICHT verwenden; das ist der dokumentierte DkvScheduler-v1-Gap (RESEARCH Pitfall 1). Je fälligem Nutzer: matches = prisma.tenderMatch.findMany({ where: { userId, notifiedAt: null }, include: { tender: true, savedSearch: true }, orderBy }); bei leer weiter. Lade user = prisma.user.findUnique({ where: { id: userId } }) für email + tenantId (bevorzugt user.tenantId für die SMTP-Auflösung). Gruppiere nach savedSearch (D-02), rufe mail.sendDigest(user, user.tenantId, sections). NUR bei erfolgreichem (nicht-„skipped") Send: prisma.tenderMatch.updateMany({ where: { id: { in: [...] } }, data: { notifiedAt: new Date(), notifiedChannel: 'digest' } }). Pro Nutzer try/catch-and-log — ein Fehler bricht den Gesamtlauf nicht ab (RESEARCH Pitfall 6). pnpm --filter api test -- tender-digest.scheduler tender-digest.scheduler.spec.ts grün: Fälligkeit daily/weekly/off + Default-daily, Multi-Tenant über findMany, eine sektionierte Mail je Nutzer, notifiedAt-Stempelung nur nach Erfolg, Robustheit bei SMTP-Fehler.

Task 3: Provider-Registrierung + SettingsModule-Import apps/api/src/tenders/tenders.module.ts Erweitere tenders.module.ts: importiere SettingsModule (aus ../settings/settings.module — es exportiert SettingsService, siehe DkvModule-Vorbild), damit TenderMailService SettingsService injizieren kann. Füge TenderMailService und TenderDigestScheduler dem providers-Array hinzu (neben den bestehenden inkl. dem in 12-01 hinzugefügten TenderMatchingService). Aktualisiere den Modul-Doc-Kommentar um den Plan-12-02-Beitrag (Digest-Kanal + mandanten-SMTP). ScheduleModule.forRoot() ist bereits global in AppModule registriert — nicht erneut importieren.

Verifiziere per Test-Build, dass die DI-Graph auflösbar ist (TenderDigestScheduler → TenderMailService → SettingsService). cd apps/api && npx tsc --noEmit -p tsconfig.json && grep -q "SettingsModule" src/tenders/tenders.module.ts && grep -q "TenderDigestScheduler" src/tenders/tenders.module.ts TendersModule importiert SettingsModule und registriert TenderMailService + TenderDigestScheduler; tsc --noEmit ist fehlerfrei (DI-Graph auflösbar).

<threat_model>

Trust Boundaries

Boundary Description
Digest-Cron (Server) → mandanten-SMTP-Relay Entschlüsselte SMTP-Credentials + Empfängeradressen verlassen den Prozess Richtung externem Mailserver
DB (SmtpConfig, verschlüsselt) → TenderMailService AES-256-GCM-Passwort wird nur im Send-Scope entschlüsselt
TenderMatch (per-user) → Digest-Mail Trefferdaten eines Nutzers dürfen nur an genau diesen Nutzer gehen

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-12-05 Information Disclosure Cross-Tenant-Digest high mitigate Digest-Query strikt where:{userId}; SMTP je Nutzer über dessen tenantId; findMany über alle Nutzer statt findFirst (Pitfall 1) — kein Cross-User/Cross-Tenant-Leak
T-12-06 Information Disclosure SMTP-Credential-Leak in Logs high mitigate decryptedPassword nur im Method-Scope, nie geloggt; generische Fehlermeldungen (DkvMailService T-07-10-Muster)
T-12-07 Denial of Service Cron-Crash durch einen defekten Mandanten high mitigate Pro-Nutzer try/catch-and-log; fehlende SmtpConfig → Skip+notifiedAt bleibt NULL; nie den ganzen Lauf abbrechen (Pitfall 6)
T-12-08 Tampering E-Mail-Injection über Tender-Titel/Profilname medium mitigate nodemailer escaped Header; Body als Text/escaped HTML, keine ungeprüfte HTML-Interpolation von Tender-Feldern
T-12-09 Elevation of Privilege Doppelversand-Umgehung des notifiedAt-Gates high mitigate Digest selektiert ausschließlich notifiedAt IS NULL; Stempelung nur nach erfolgreichem Send → strukturell kein Doppelversand (D-06)
T-12-SC Tampering npm/pip/cargo installs high accept Keine Paketinstallation (RESEARCH Package Legitimacy Audit: keine Neuinstallation)
</threat_model>
- `pnpm --filter api test -- tender-mail.service` grün - `pnpm --filter api test -- tender-digest.scheduler` grün - `npx tsc --noEmit` fehlerfrei (DI-Graph auflösbar) - Manuell/UAT (end-of-phase): Digest-Testlauf gegen lokales Mailhog (localhost:1025) zeigt genau eine sektionierte Mail je Nutzer

<success_criteria>

  • Nutzer mit offenen Matches und daily/Default erhält beim Digest-Lauf genau eine nach Profil gegliederte Mail über seine mandanten-SMTP
  • Zwei Nutzer/zwei Mandanten daily → beide erhalten je eine Mail (findMany, kein findFirst)
  • Nach Versand tragen die Matches notifiedAt + channel='digest'; kein erneuter Versand
  • Fehlende SMTP eines Mandanten bricht den Lauf nicht ab </success_criteria>
Create `.planning/phases/12-tender-notifications/12-02-SUMMARY.md` when done