Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
40 KiB
Phase 12: Tender Notifications — Research
Researched: 2026-07-22 Domain: E-Mail-Benachrichtigung über eine bestehende NestJS-Poll/Match-Pipeline (matched-vs-notified-State, per-user Digest-Scheduler, per-profile Sofort-Alert, mandanten-SMTP-Versand) Confidence: HIGH (alles gegen den Live-Code + Live-DB verifiziert; keine neuen externen Pakete) Sprache: Prosa Deutsch, Code/Identifier Englisch (response_language: de)
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions (verbatim aus 12-CONTEXT.md)
Digest (NOTIFY-01)
- D-01: Wählbare Intervalle im Webinterface: Täglich (Default), Wöchentlich, Aus. (Stündlich bewusst NICHT angeboten.) „Aus" = kein Digest, Nutzer verlässt sich auf Sofort-Alerts.
- D-02: Digest-Aufbau: eine einzige Mail pro Nutzer, innerhalb nach Suchprofil gegliedert (Abschnitte je Profil). Nicht eine Mail pro Profil.
- D-03: Digest-Intervall ist eine Nutzer-Einstellung (pro Nutzer, mandantenbewusst), nicht pro Profil. Ein Digest deckt alle Profile des Nutzers ab.
Sofort-Alert (NOTIFY-02)
- D-04: Sofort-Alerts sind pro Suchprofil aktivierbar, Default AUS.
- D-05: Ein Sofort-Alert = eine Mail kurz nach einem neuen Treffer gegen dieses Profil. Mehrere gleichzeitig neu eingelesene Treffer desselben Profils dürfen zu einer Sammel-Sofort-Mail gebündelt werden (Claude's Discretion: Einzelmail vs. kurze Bündelung pro Poll-Tick) — aber nie ein Alert je Profil-Treffer-Paar, das schon benachrichtigt wurde.
Getroffen vs. Benachrichtigt (NOTIFY-03) — Kern-Invariante
- D-06: Expliziter matched-vs-notified-State. Ein Treffer (Tender × Suchprofil) wird als eigener Datensatz erfasst („getroffen"), separat davon der Benachrichtigungsstatus („benachrichtigt via digest/instant"). Ein Tender+Profil-Paar wird nie zweimal benachrichtigt — weder Digest und Sofort, noch zweimal im selben Kanal.
- D-07: Rückstau-Unterdrückung beim Anlegen/Aktivieren eines Suchprofils: vorhandene historische Treffer (der ~2150 Bestands-Ausschreibungen) werden beim ersten Aktivieren als „bereits benachrichtigt"/unterdrückt markiert. Nur ab-jetzt-neue Treffer lösen Benachrichtigungen aus.
Versand (NOTIFY-04)
- D-08: E-Mail-Versand über die mandantenspezifische SMTP-Konfiguration (
SettingsService/SmtpConfig+MailModule, exakt wieDkvMailService), NICHT über einen globalen System-Mailer.
Claude's Discretion (verbatim)
- Matching-Mechanismus: bevorzugt Wiederverwendung der Phase-11-
tender-query.builder.ts-where-Logik (Profil-filters-JSON → Prisma-where), ausgelöst am Ende des Ingestion-Poll-Ticks (TenderIngestionService.pollDueSources) oder als separater Match-Scheduler. Match-Granularität, Batch-Größe, Performance. - Datenmodell: neue Tabelle(n) für Treffer + Benachrichtigungsstatus (z.B.
TenderMatchmitnotifiedDigestAt/notifiedInstantAtoder getrennte notification-log-Tabelle) — Namen, Unique-Constraints (Tender×Profil), Scoping (where:{userId}wie Phase-11-Triage, kein RLS/forTenant). - Digest-Scheduler (
@nestjs/schedule), Zeitpunkt-Berechnung (täglich/wöchentlich), Sofort-Alert-Auslösung am Poll-Tick. - E-Mail-Templating (HTML/Text) — schlicht, hardcodiertes Deutsch (i18n = Phase 14).
- Web-UI: Digest-Intervall-Einstellung (pro Nutzer) + Sofort-Alert-Toggle pro Profil (erweitert die Phase-11-
SavedSearchBar/Settings).
Deferred Ideas (OUT OF SCOPE)
- Scraping-Adapter + Cross-Source-Dedup (Phase 13), RSS/E-Mail-Quellen-Ingestion + Admin-Quellen-UI + i18n-Rollout + ausgeschlossene-Portale-UI (Phase 14).
- Stündlicher Digest (D-01). In-App-/Push-/Webhook-Benachrichtigungen — nur E-Mail. </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Beschreibung | Research Support |
|---|---|---|
| NOTIFY-01 | Periodischer E-Mail-Digest, Intervall im Webinterface konfigurierbar | Pattern D (Digest-Scheduler, ein globaler Cron), Datenmodell TenderNotificationPref (per-user digestInterval), UI erweitert Settings-Page |
| NOTIFY-02 | Optionaler Sofort-Alert pro aktivem Suchprofil, im Webinterface aktivierbar | Pattern E (Instant am Poll-Tick), Schema TenderSavedSearch.instantAlert Boolean, UI-Toggle in SavedSearchBar |
| NOTIFY-03 | „getroffen" vs. „benachrichtigt"; kein Doppelversand, kein Rückstau-Massenversand | Pattern B (TenderMatch + notifiedAt-Invariante) + Pattern C (delta-only Matching = strukturelle Rückstau-Vermeidung) |
| NOTIFY-04 | Versand über mandantenspezifische SMTP-Konfig (DKV-Muster) | Pattern F (TenderMailService als 1:1-Kopie des DkvMailService-Musters, getDecryptedSmtpConfig(tenantId)) |
| </phase_requirements> |
Summary
Phase 12 baut keine neue Infrastruktur — sie verdrahtet drei bereits fertige Bausteine: (1) die Phase-11-Filterlogik buildTenderWhere(filters), (2) den Phase-10-Poll-Tick TenderIngestionService.pollDueSources, und (3) den DKV-Mandanten-SMTP-Versandpfad (DkvMailService → SettingsService.getDecryptedSmtpConfig(tenantId) → frischer nodemailer-Transport pro Send). Der gesamte fachliche Kern reduziert sich auf ein neues Datenmodell (TenderMatch mit einem notifiedAt-Zustand) plus zwei Auslöser (Match am Poll-Tick, Digest per globalem Cron). Es werden keine neuen npm-Pakete benötigt.
Die Kern-Invariante von NOTIFY-03 („nie zweimal, kein Rückstau-Flut") lässt sich am elegantesten strukturell statt durch nachträgliche Unterdrückung lösen: Wenn das Matching ausschließlich gegen die in diesem Poll-Tick neu angelegten Tender läuft (delta-only) und die Profil-Erstellung keinen historischen Full-Table-Rescan auslöst, kann ein Rückstau-Flut per Konstruktion nicht entstehen — ein neu angelegtes Profil sammelt Treffer erst ab dem nächsten Tick. Damit ist D-07 („nur ab jetzt", identisch zur Phase-10-Day-Cursor-Philosophie) ohne explizite „suppressed rows"-Backfilltabelle erfüllt. Der Doppelversand-Schutz ist ein einzelnes nullable notifiedAt je (tender × savedSearch)-Paar: ist es gesetzt (egal ob durch Instant oder Digest), wird das Paar nie wieder benachrichtigt.
Der wichtigste Fallstrick ist der Multi-Tenant-Scheduler: Der Digest-Cron muss — wie der Phase-10-TenderSchedulerService und entgegen dem DkvSchedulerService (dokumentiert als „v1 single-tenant, findFirst()") — ein einziger globaler Job sein, der per findMany über alle fälligen Nutzer aller Mandanten iteriert und je Nutzer über dessen tenantId den korrekten SMTP-Transport auflöst. Ein per-Tenant-Cron oder ein findFirst würde nur den ersten Mandanten bedienen.
Primary recommendation: Neue Tabelle TenderMatch (@@unique([tenderId, savedSearchId]), denormalisiert userId+tenantId, ein notifiedAt DateTime? + notifiedChannel String?) + TenderSavedSearch.instantAlert Boolean @default(false) + neue Tabelle TenderNotificationPref (per-user digestInterval). Matching delta-only am Ende von pollDueSources (nur neu angelegte Tender-IDs dieses Ticks). Instant im selben Tick, Digest als ein globaler @nestjs/schedule-Cron. Versand via neuem TenderMailService (1:1-Klon des DkvMailService-Musters).
Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| Match-Erzeugung (Tender × Profil) | API / Backend (TenderMatchingService) |
Database (TenderMatch upsert) |
Reine Serverlogik am Poll-Tick; Postgres macht die Filterarbeit (indexierte where) |
| matched-vs-notified-State | Database (TenderMatch.notifiedAt) |
API (Invariantenprüfung) | Der Zustand IST die Zeile; die Invariante ist ein WHERE notifiedAt IS NULL-Gate |
| Rückstau-Unterdrückung (D-07) | API (delta-only Matching-Grenze) | — | Strukturell: Matching sieht nur Neu-diesen-Tick-Tender; kein Historien-Rescan |
| Digest-Terminierung | API (@nestjs/schedule, ein globaler Cron) |
Database (TenderNotificationPref) |
Poll-once-fan-out-many-Muster (Phase 10), kein per-Tenant-Job |
| Sofort-Alert-Auslösung | API (pollDueSources-Ende) |
— | Synchron im Tick, direkt nach Match-Erzeugung (DKV-Direktaufruf-Stil) |
| SMTP-Versand | API (TenderMailService) |
Settings/Crypto (getDecryptedSmtpConfig) |
Mandanten-SMTP, frischer Transport pro Send (Pitfall-3-Mitigation) |
| Digest-Intervall-UI / Instant-Toggle | Frontend (Next.js, native fetch) |
API (neue REST-Routen) | Erweitert Phase-11-SavedSearchBar + Settings-Page |
Standard Stack
Core (alles bereits im Projekt installiert — verifiziert gegen apps/api/package.json)
| Library | Version (installiert) | Purpose | Why Standard |
|---|---|---|---|
nodemailer |
^9.0.1 |
SMTP-Versand, frischer Transport pro Send | Bereits vom DkvMailService genutzt; exakt dasselbe Muster [VERIFIED: apps/api/package.json] |
@nestjs/schedule |
^6.1.3 |
Digest-Cron via SchedulerRegistry |
Bereits vom TenderSchedulerService/DkvSchedulerService genutzt [VERIFIED: apps/api/package.json] |
cron |
4.4.0 |
CronJob-Klasse (transitive Peer, via require('cron')) |
Exakt der Phase-10-Resolution-Workaround, verbatim wiederverwenden [VERIFIED: tender-scheduler.service.ts:14] |
@prisma/client / prisma |
^6.0.0 |
ORM, neue Modelle + Migration | Handgeschriebene timestamped Migration wie alle Phase-10/11-Migrationen [VERIFIED: apps/api/package.json] |
Hinweis: CLAUDE.md nennt aspirativ Prisma 7.8.x / Next.js 16; installiert und maßgeblich ist Prisma
^6.0.0. Der Planner MUSS gegen die installierte Version planen, nicht gegen die CLAUDE.md-Wunschversion. [VERIFIED: apps/api/package.json]
Supporting (bereits vorhanden, wiederverwenden)
| Asset | Purpose | When to Use |
|---|---|---|
SettingsService.getDecryptedSmtpConfig(tenantId) |
Entschlüsselte SMTP-Konfig pro Mandant | Im TenderMailService pro Send [VERIFIED: settings.service.ts:88] |
CalendarCryptoService |
AES-256-GCM decrypt (via CalendarModule.exports) |
Transitiv über SettingsService — nicht direkt nötig [VERIFIED: calendar/crypto.service.ts] |
buildTenderWhere(dto) |
Profil-filters → Prisma-where |
Im Matching, filters-JSON als TenderQueryDto casten [VERIFIED: tender-query.builder.ts:35] |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
Ein notifiedAt-Feld |
Zwei Felder notifiedDigestAt/notifiedInstantAt |
Zwei Felder machen die Invariante SCHWERER (man muss beide auf NULL prüfen) und suggerieren fälschlich, ein Paar dürfe zweimal (je Kanal) benachrichtigt werden — was D-06 explizit verbietet. Ein einzelnes notifiedAt + notifiedChannel erfüllt D-06 einfacher. Siehe Pattern B. |
Globaler MailService/@nestjs-modules/mailer |
— | Verboten durch D-08: das ist der System-Mailer (Passwort-Reset), fester Startup-Transport, kein Mandanten-SMTP [VERIFIED: mail.module.ts] |
| Backfill via „suppressed rows" bei Profil-Erstellung | delta-only Matching (kein Backfill) | Empfohlen: delta-only vermeidet ~2188 × N suppressed rows und erfüllt D-07 strukturell. Siehe Pattern C. |
Installation: Keine. Es werden keine neuen Pakete installiert.
Package Legitimacy Audit
Diese Phase installiert keine externen Pakete — alle benötigten Libraries (
nodemailer,@nestjs/schedule,cron,prisma) sind bereits Bestand vonapps/api/package.jsonund wurden in Phase 7/10 legitimiert.
| Package | Registry | Disposition |
|---|---|---|
| — (keine Neuinstallation) | — | N/A |
Packages removed due to [SLOP] verdict: none Packages flagged as suspicious [SUS]: none
Architecture Patterns
System Architecture Diagram
[Cron-Tick: TenderSchedulerService] (bestehend, unverändert)
│
▼
[TenderIngestionService.pollDueSources] ── ERWEITERN: neue Tender-IDs dieses Ticks sammeln
│ (Tender.upsert-Schleife; sammelt IDs der NEU angelegten Rows)
▼
[TenderMatchingService.matchDelta(newTenderIds)] ← NEU
│ für jedes TenderSavedSearch: buildTenderWhere(filters) AND id IN (newTenderIds)
▼
[prisma.tenderMatch.upsert(@@unique(tenderId,savedSearchId))] notifiedAt = null
│
├───────────────► [Instant] Profile mit instantAlert=true:
│ pro Profil eine Sammel-Mail (D-05) → notifiedAt=now, channel='instant'
│
▼ (asynchron, entkoppelt)
[Digest-Cron (EIN globaler @nestjs/schedule-Job)] ← NEU
│ findMany über ALLE fälligen Nutzer (daily immer / weekly montags), alle Mandanten
│ je Nutzer: TenderMatch WHERE userId=? AND notifiedAt IS NULL, gruppiert nach Profil
▼
[TenderMailService.sendDigest / sendInstant] ← NEU (Klon des DkvMailService-Musters)
│ getDecryptedSmtpConfig(user.tenantId) → nodemailer.createTransport() pro Send → close()
▼
[Mandanten-SMTP-Relay] danach: notifiedAt=now, channel='digest' auf allen versendeten Matches
Recommended Project Structure
apps/api/src/tenders/
├── tender-matching.service.ts # NEU: matchDelta(newTenderIds), Instant-Dispatch
├── tender-mail.service.ts # NEU: sendInstant/sendDigest (DkvMailService-Klon)
├── tender-digest.scheduler.ts # NEU: EIN globaler Cron, findMany über fällige Nutzer
├── tender-notification-pref.service.ts # NEU: per-user digestInterval CRUD
├── dto/notification-pref.dto.ts # NEU
├── tender-ingestion.service.ts # ERWEITERN: neue Tender-IDs sammeln + matchDelta aufrufen
├── tender-saved-search.service.ts # ERWEITERN: instantAlert in create/update
└── tenders.controller.ts # ERWEITERN: Routen für pref + instant-toggle
apps/api/prisma/migrations/
└── 2026072x000000_add_tender_notifications/ # TenderMatch, TenderNotificationPref, instantAlert
apps/web/src/app/(portal)/modules/tender-radar/
├── components/SavedSearchBar.tsx # ERWEITERN: Instant-Toggle pro Profil
├── settings/page.tsx # ERWEITERN: Digest-Intervall-Auswahl (per-user)
└── (lib/tender-radar-api.ts) # ERWEITERN: neue API-Funktionen
Pattern A: Schema — TenderMatch + per-user Pref + per-profile Flag
What: Drei Schema-Änderungen, alle nach dem Phase-11-Scoping-Muster (userId-Scoping im Service, KEIN forTenant()/RLS; tenantId denormalisiert mitgeführt für spätere Isolation + SMTP-Auflösung).
// NEU — der „getroffen"-Datensatz (D-06). Ein Row je (tender × savedSearch).
model TenderMatch {
id String @id @default(uuid())
tenderId String
savedSearchId String
userId String // denormalisiert (Phase-11-Muster: direktes where:{userId})
tenantId String // denormalisiert — für SMTP-Auflösung im Digest-Cron
matchedAt DateTime @default(now())
notifiedAt DateTime? // NULL = noch nicht benachrichtigt (das Eligibility-Gate)
notifiedChannel String? // 'digest' | 'instant' — nur Audit, NICHT Teil der Invariante
tender Tender @relation(fields: [tenderId], references: [id], onDelete: Cascade)
savedSearch TenderSavedSearch @relation(fields: [savedSearchId], references: [id], onDelete: Cascade)
@@unique([tenderId, savedSearchId]) // Upsert-Target — ein Match je Paar, idempotent
@@index([userId]) // Digest-Query: where userId, notifiedAt null
@@index([notifiedAt])
}
// NEU — Digest-Intervall ist per-USER (D-03), nicht per-Profil.
model TenderNotificationPref {
id String @id @default(uuid())
userId String @unique // ein Pref-Row je Nutzer
tenantId String
digestInterval String @default("daily") // 'daily' | 'weekly' | 'off' (D-01)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
// ERWEITERN TenderSavedSearch (D-04): Sofort-Alert pro Profil, Default AUS.
model TenderSavedSearch {
// ... bestehende Felder ...
instantAlert Boolean @default(false)
matches TenderMatch[] // Gegenrelation
}
When to use: immer. Tender bekommt zusätzlich triage TenderTriage[] schon; jetzt zusätzlich matches TenderMatch[] als Gegenrelation.
Rationale notifiedAt (ein Feld): Das Eligibility-Gate ist notifiedAt IS NULL. Sobald irgendein Kanal gesendet hat, ist das Feld gesetzt → das Paar ist raus. Das erfüllt D-06 („nie zweimal — weder Digest UND Sofort, noch zweimal im selben Kanal") direkt. notifiedChannel ist nur Audit.
Pattern B: matched-vs-notified-Invariante (NOTIFY-03 Kern)
What: Die Nicht-Doppelversand-Garantie ist ein Prädikat: WHERE notifiedAt IS NULL.
- Match-Erzeugung:
upsertauf@@unique([tenderId, savedSearchId]),update: {}(idempotent — bestehendesnotifiedAtbleibt unangetastet bei Re-Match). - Instant sendet → setzt
notifiedAt=now, channel='instant'. - Digest selektiert nur
notifiedAt IS NULL→ sieht ein instant-versendetes Paar nie → kein Doppelversand. - Reihenfolge garantiert Korrektheit: Instant läuft synchron im Poll-Tick, Digest läuft später (täglich) → Instant setzt
notifiedAtimmer VOR dem Digest-Lauf.
Example (Match-Upsert, idempotent):
// tender-matching.service.ts
await this.prisma.tenderMatch.upsert({
where: { tenderId_savedSearchId: { tenderId, savedSearchId: search.id } },
create: {
tenderId, savedSearchId: search.id,
userId: search.userId, tenantId: search.tenantId,
// notifiedAt bleibt null → wird vom nächsten Digest/Instant abgeholt
},
update: {}, // Re-Match ändert notifiedAt NICHT → keine Wieder-Benachrichtigung
});
Warum das sicher ist: Selbst wenn matchDelta versehentlich ein bereits gematchtes Paar erneut sieht, bewahrt update: {} das gesetzte notifiedAt. Die Invariante hält strukturell, nicht per Konvention.
Pattern C: Rückstau-Unterdrückung = delta-only Matching (D-07)
What: Matching läuft ausschließlich gegen die in diesem Tick neu angelegten Tender — nie gegen die volle Tabelle. Profil-Erstellung löst keinen Historien-Rescan aus.
Konsequenz: Ein neu angelegtes Profil kann per Konstruktion keine Flut erzeugen — die ~2188 Bestands-Tender wurden in vergangenen Ticks eingelesen und werden nie wieder an matchDelta übergeben. Das Profil sammelt Treffer erst ab dem nächsten Poll-Tick. Damit ist D-07 („nur ab jetzt", konsistent mit Phase-10-D-01 + Day-Cursor-Gate) erfüllt, OHNE ~2188 × N „suppressed rows" schreiben zu müssen.
Wie „neu diesen Tick" ermittelt wird: pollDueSources muss erweitert werden, um die IDs der neu angelegten Tender zu sammeln. Der bestehende upsert meldet nicht create-vs-update. Empfohlener Ansatz — Vorab-Check in der Schleife:
// in der bestehenden Schleife von pollDueSources, VOR dem upsert:
const existing = await this.prisma.tender.findUnique({
where: { dedupKey: tender.dedupKey }, select: { id: true },
});
// ... upsert wie gehabt ...
const saved = await this.prisma.tender.upsert({ /* ... */ });
if (!existing) newTenderIds.push(saved.id); // nur genuin NEUE Rows
Ein indexierter findUnique je Record (Dutzende/Tag bei DÖE) ist vernachlässigbar. Am Tick-Ende: await this.matching.matchDelta(newTenderIds).
MVP-Vereinfachung: Nur genuin neue Tender lösen Matching/Benachrichtigung aus — contentHash-Änderungen (Fristverlängerung etc.) werden für Phase 12 nicht als Re-Benachrichtigung behandelt. Das sidesteppt die „Notice aktualisiert"-Frage, die CONTEXT nicht fordert. (Offene Frage 1.)
Trade-off / bewusst akzeptiert: Ein Profil, das nach dem Einlesen eines passenden Tenders angelegt wird, „verpasst" diesen Tender in der Benachrichtigung — er ist aber weiterhin normal in der Phase-11-Trefferliste sichtbar. Das ist exakt das gewünschte „nur ab jetzt"-Verhalten, kein Bug.
Pattern D: Digest-Scheduler — EIN globaler Cron (NOTIFY-01)
What: Genau ein platform-weiter @nestjs/schedule-Cron (kein per-Tenant-Job), der nach dem Phase-10-TenderSchedulerService-Muster registriert wird (nicht dem DkvSchedulerService-findFirst-Muster).
- Läuft z.B. täglich 07:00 Europe/Berlin (
0 7 * * *). - Selektiert fällige Nutzer per
findMany(nichtfindFirst!):digestInterval='daily'immer,digestInterval='weekly'nur an einem festen Wochentag (z.B. Montag). - Je Nutzer:
TenderMatch WHERE userId=? AND notifiedAt IS NULL, nachsavedSearchIdgruppiert (D-02: eine Mail, Abschnitte je Profil). - Sendet eine Mail via
TenderMailService.sendDigest, dannupdateManynotifiedAt=now, channel='digest'auf alle einbezogenen Match-IDs.
// tender-digest.scheduler.ts — Fälligkeits-Selektion (Kern)
const weekday = new Date().toLocaleDateString('en-US',
{ timeZone: 'Europe/Berlin', weekday: 'short' }); // 'Mon'...
const dueUsers = await this.prisma.tenderNotificationPref.findMany({
where: {
OR: [
{ digestInterval: 'daily' },
...(weekday === 'Mon' ? [{ digestInterval: 'weekly' }] : []),
],
},
});
// KEIN findFirst — sonst nur der erste Mandant (DkvScheduler-Pitfall).
Multi-Tenant-Sicherheit: Der Cron ist ein Singleton-Job; er iteriert Nutzer aller Mandanten und löst pro Nutzer über dessen tenantId den SMTP-Transport auf. Kein per-Tenant-Cron, kein findFirst.
Pattern E: Sofort-Alerts (NOTIFY-02)
What: Direkt nach matchDelta im selben Poll-Tick: für Profile mit instantAlert=true die in diesem Tick neu erzeugten (noch notifiedAt IS NULL) Matches je Profil zu einer Sammel-Mail bündeln (D-05), senden, notifiedAt=now, channel='instant' setzen.
// tender-matching.service.ts (Ende von matchDelta)
const instantSearches = savedSearches.filter((s) => s.instantAlert);
for (const search of instantSearches) {
const fresh = await this.prisma.tenderMatch.findMany({
where: { savedSearchId: search.id, notifiedAt: null,
tenderId: { in: newTenderIds } },
include: { tender: true },
});
if (!fresh.length) continue;
await this.mail.sendInstant(search, fresh.map((m) => m.tender)); // eine Sammel-Mail
await this.prisma.tenderMatch.updateMany({
where: { id: { in: fresh.map((m) => m.id) } },
data: { notifiedAt: new Date(), notifiedChannel: 'instant' },
});
}
Wichtig: Instant läuft synchron (DKV-Direktaufruf-Stil, kein Message-Broker im Stack). Ein Fehlschlag darf den Tick nicht crashen — catch-and-log pro Profil; bei Sendefehler notifiedAt NICHT setzen → Retry beim nächsten Tick/Digest.
Pattern F: Mandanten-SMTP-Versand (NOTIFY-04)
What: TenderMailService ist ein 1:1-Klon des DkvMailService-Musters:
const smtp = await this.settingsService.getDecryptedSmtpConfig(tenantId);
if (!smtp) { /* log + skip; notifiedAt NICHT setzen → Retry */ return; }
const transport = nodemailer.createTransport({
host: smtp.host, port: smtp.port,
secure: smtp.encryption === 'ssl-tls',
requireTLS: smtp.encryption === 'starttls',
auth: smtp.username ? { user: smtp.username, pass: smtp.decryptedPassword ?? '' } : undefined,
});
try { await transport.sendMail({ from: smtp.fromAddress, to: userEmail, subject, text, html }); }
finally { transport.close(); } // WR-01: Pool freigeben, sonst Socket-Leck bei Retry
userEmailkommt ausUser.email(verifiziert vorhanden,@unique) — im Digest-Cron perinclude/join vom Pref/Match zum User laden.- Templating: schlichtes Deutsch, hardcodiert (i18n = Phase 14). Text + optional HTML. Digest sektioniert nach Profil (D-02): je Profil Überschrift + Liste (Titel, Auftraggeber, Frist, geschätzter Wert,
sourceUrl-Link).estimatedValueist ein String und zu 91,6 % null — nie ungeprüftNumber()-coercen (Phase-11-Hinweis). - Kein globaler Mailer:
MailService/@nestjs-modules/mailerbleibt unangetastet (System-Mails only, D-08).
Anti-Patterns to Avoid
findFirstim Digest-Cron → bedient nur den ersten Mandanten (exakt der dokumentierteDkvSchedulerService-v1-Gap). ImmerfindManyüber alle fälligen Nutzer.- Per-Tenant-Cron-Job für Digest → unnötig; ein globaler Job iteriert Nutzer intern. (Per-Tenant-Cron ist erst in Phase 14 für Email-Alert-Inbox nötig.)
- Historischer Full-Table-Rescan bei Profil-Erstellung → Rückstau-Flut (D-07-Verstoß). Delta-only Matching.
where: { tenantId }aufTender/Match-Reads by habit →Tenderist global (D-03); Match-Scoping läuft überuserId.- Zwei
notifiedAt-Spalten → verkompliziert die Invariante; ein Feld genügt. nodemailer-Transport auf Modul-Startup cachen → Admin-SMTP-Änderung greift erst nach Restart (Pitfall 3). Frischer Transport pro Send +transport.close().
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
Filter-where aus Profil-filters |
Neue Match-Filterlogik | buildTenderWhere(filters as TenderQueryDto) |
Phase-11-Logik ist getestet, deckt alle FILTER-Kriterien + null-Handling ab [VERIFIED: tender-query.builder.ts] |
| SMTP-Transport + Entschlüsselung | Eigener Mailer | DkvMailService-Muster + getDecryptedSmtpConfig |
Verschlüsselung, Transport-Lifecycle, Fehler-Handling gelöst [VERIFIED: dkv-mail.service.ts] |
| Dynamischer Cron | @Cron()-Dekorator (statisch) |
SchedulerRegistry.addCronJob (Phase-10-Muster) |
Intervall/Registrierung zur Laufzeit, kein Restart [VERIFIED: tender-scheduler.service.ts] |
| Doppelversand-Dedup | Eigene Sent-Log-Tabelle + Cross-Check | Ein notifiedAt-Feld + @@unique |
Die Unique-Constraint + Nullable-Feld IST das Dedup |
| Credential-Entschlüsselung | Eigenes AES | CalendarCryptoService (transitiv via SettingsService) |
Bereits im Versandpfad [VERIFIED: settings.service.ts:105] |
Key insight: Diese Phase ist fast reine Verdrahtung. Jeder „neue" Baustein hat ein exaktes Vorbild im Repo (DkvMailService, TenderSchedulerService, TenderTriageService-Scoping, buildTenderWhere). Abweichungen vom Vorbild sind fast immer ein Bug.
Common Pitfalls
Pitfall 1: Multi-Tenant-Digest via findFirst
What goes wrong: Nur der erste Mandant erhält Digests; alle anderen Nutzer bekommen nie eine Mail.
Why it happens: Copy-Paste aus DkvSchedulerService (dort explizit als „v1 single-tenant, findFirst()" dokumentiert).
How to avoid: Ein globaler Cron + prisma.tenderNotificationPref.findMany(...) über alle fälligen Nutzer; SMTP je Nutzer über dessen tenantId.
Warning signs: Test „zwei Nutzer in zwei Mandanten, beide daily" → nur einer bekommt Mail.
Pitfall 2: Rückstau-Flut bei Profil-Erstellung
What goes wrong: Neues Profil matcht sofort ~2188 Bestands-Tender → 2188 Instant-Mails oder ein Riesen-Digest.
Why it happens: Matching gegen die volle Tabelle statt gegen das Tick-Delta; oder Backfill-on-create ohne Suppression.
How to avoid: delta-only Matching (Pattern C); Profil-Erstellung triggert kein Matching.
Warning signs: Test „Profil anlegen bei 2188 vorhandenen Tendern" → erzeugt >0 notifiedAt=null-Matches. Erwartung: 0.
Pitfall 3: Doppelversand Instant + Digest
What goes wrong: Ein Paar wird per Instant UND im Digest verschickt.
Why it happens: Digest-Query filtert nicht auf notifiedAt IS NULL, oder Instant setzt notifiedAt nicht.
How to avoid: Instant setzt notifiedAt+channel sofort nach erfolgreichem Send; Digest selektiert strikt notifiedAt IS NULL.
Warning signs: Test „Profil instant=on, user digest=daily, ein neuer Match" → genau EINE Mail insgesamt.
Pitfall 4: SMTP-Transport auf Startup gecacht
What goes wrong: Admin ändert SMTP, Mails gehen weiter über alte Config bis Restart.
Why it happens: @nestjs-modules/mailer cached Transport; oder eigener Transport im Konstruktor.
How to avoid: nodemailer.createTransport() pro Send + transport.close() im finally (DKV-Muster).
Warning signs: SMTP-Änderung wirkt erst nach Neustart.
Pitfall 5: Tender ohne Mandant, Match mit Mandant
What goes wrong: where: { tenantId } auf Tender-Reads filtert globale Daten für den 2. Mandanten weg; oder Match-Reads ohne userId-Scoping (IDOR).
Why it happens: Blindes Kopieren des Standard-Mandanten-Scopings.
How to avoid: Tender bleibt global (D-03, kein RLS/forTenant); TenderMatch-Reads immer where:{userId} (Phase-11-Triage-Muster).
Warning signs: Digest eines Nutzers enthält Matches eines anderen Nutzers/Mandanten.
Pitfall 6: Fehlender SMTP-Config → Cron-Crash
What goes wrong: Ein Mandant ohne SmtpConfig wirft im Digest-Cron, der ganze Tick bricht ab, andere Nutzer bekommen nichts.
Why it happens: getDecryptedSmtpConfig liefert null → DkvMailService-Muster wirft.
How to avoid: Pro Nutzer catch-and-log; bei fehlender Config skip + notifiedAt NICHT setzen (Retry nächster Lauf). Nie den ganzen Cron-Lauf abbrechen.
Warning signs: Ein Mandant ohne SMTP verhindert Mails aller anderen.
Code Examples
Match am Poll-Tick verdrahten (Ingestion-Erweiterung)
// tender-ingestion.service.ts — am Ende von pollDueSources, nach der Fetch-Schleife:
if (newTenderIds.length) {
await this.matching.matchDelta(newTenderIds); // erzeugt Matches + feuert Instant
}
Digest-Selektion + eine Mail pro Nutzer (D-02)
for (const pref of dueUsers) {
const matches = await this.prisma.tenderMatch.findMany({
where: { userId: pref.userId, notifiedAt: null },
include: { tender: true, savedSearch: true },
orderBy: { savedSearch: { name: 'asc' } },
});
if (!matches.length) continue;
const user = await this.prisma.user.findUnique({ where: { id: pref.userId } });
if (!user) continue;
// nach Profil gruppieren → eine sektionierte Mail
await this.mail.sendDigest(user, pref.tenantId, groupByProfile(matches));
await this.prisma.tenderMatch.updateMany({
where: { id: { in: matches.map((m) => m.id) } },
data: { notifiedAt: new Date(), notifiedChannel: 'digest' },
});
}
State of the Art
| Old Approach | Current Approach | Impact |
|---|---|---|
ARCHITECTURE.md (pre-Phase-11): TenderSavedSearch mit strukturierten Spalten (keywords[], notifyMode, digestHour) |
Phase 11 shippte filters Json + KEINE notify-Felder |
Planner MUSS gegen die echte Phase-11-Schema-Realität planen: filters ist ein JSON-Blob, notifyMode/digestHour existieren NICHT. Digest-Intervall wandert auf per-user (TenderNotificationPref, D-03), Instant auf per-profile (instantAlert, D-04) |
ARCHITECTURE.md: TenderMatch.notifiedAt (singulär) + „last N days" Backfill |
Ein notifiedAt bleibt korrekt; Backfill durch delta-only ersetzt (kein „last N days"-Rescan) |
D-07 wird strukturell statt durch Suppression erfüllt |
Deprecated/outdated:
- ARCHITECTURE.md § „Pattern 4 … Backfill against tenders from the last N days": durch delta-only Matching überholt — kein Backfill nötig. Historische Tender bleiben in der UI sichtbar, lösen aber keine Mail aus.
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | Ein täglicher Digest-Cron um 07:00 Europe/Berlin (weekly montags) genügt D-01 | Pattern D | Niedrig — D-01 fordert nur daily/weekly/off, keinen per-hour-Zeitpunkt. Falls Nutzer eine feste Uhrzeit wählen wollen, wird TenderNotificationPref um digestHour erweitert. |
| A2 | contentHash-Änderungen lösen in Phase 12 KEINE Re-Benachrichtigung aus (nur genuin neue Tender) |
Pattern C | Mittel — falls „Notice aktualisiert"-Mails gewünscht sind, muss der Delta zusätzlich geänderte IDs führen. CONTEXT fordert es nicht. |
| A3 | Digest-Empfänger-Adresse = User.email |
Pattern F | Niedrig — User.email ist @unique, verifiziert vorhanden. |
| A4 | Fehlende Mandanten-SMTP → skip+log, Match bleibt notifiedAt=null (Retry) |
Pitfall 6 | Niedrig — akzeptables MVP-Verhalten; Alternative wäre ein „dead-letter"-Flag, für MVP unnötig. |
| A5 | Matching läuft am Ende von pollDueSources (kein separater Match-Scheduler) |
Pattern C | Niedrig — CONTEXT nennt beides als Discretion; der Poll-Tick ist der natürliche Delta-Auslöser. |
Open Questions
-
Re-Benachrichtigung bei Änderung (
contentHash)?- Was wir wissen: Phase 10 erkennt Änderungen via
contentHash(SCHEMA-02), aktualisiert aberpollDueSourcessammelt aktuell keine geänderten IDs. - Was unklar ist: Soll eine Fristverlängerung/Aufhebung eine „Notice aktualisiert"-Mail auslösen?
- Empfehlung: Für MVP nein — nur genuin neue Tender benachrichtigen (A2). Beim Discuss/Planning bestätigen.
- Was wir wissen: Phase 10 erkennt Änderungen via
-
Feste Digest-Uhrzeit pro Nutzer?
- Was wir wissen: D-01/D-03 fordern nur Intervall (daily/weekly/off), keine Uhrzeit.
- Empfehlung: Ein globaler 07:00-Lauf;
digestHournur nachrüsten, falls gefordert (A1).
-
Was passiert mit
notifiedAt=null-Matches, wenn digestInterval='off' UND instantAlert=false?- Was wir wissen: Solche Matches würden sich unbegrenzt ansammeln (nie versendet).
- Empfehlung: Akzeptabel — sie sind einfach „stiller" State; die UI zeigt Treffer ohnehin. Optional: bei „off" den Match direkt als suppressed markieren. Für MVP: nichts tun (harmlos). Beim Planning entscheiden.
Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
Mandanten-SmtpConfig (DB-Row) |
NOTIFY-04 Versand | pro Mandant, optional | — | Skip+Log, Match bleibt un-notified (Pitfall 6) |
nodemailer |
Versand | ✓ | ^9.0.1 |
— |
@nestjs/schedule + cron |
Digest-Cron | ✓ | ^6.1.3 / 4.4.0 |
— |
| PostgreSQL (Migration) | Schema | ✓ | 16 (Docker) | — |
| SMTP-Relay (Mailhog lokal) | UAT/Test | lokal localhost:1025 |
— | Test-Send prüft nur Transportaufbau |
Missing dependencies with no fallback: keine — alle Runtime-Abhängigkeiten vorhanden. Missing dependencies with fallback: Mandanten-SMTP ist Nutzer-konfiguriert; ohne Config wird sauber übersprungen.
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Vitest 3.x (ESM) [VERIFIED: apps/api/package.json scripts.test = vitest run] |
| Config file | apps/api/vitest.config.ts [VERIFIED: existiert] |
| Quick run command | pnpm --filter api test -- <spec> |
| Full suite command | pnpm --filter api test (bzw. Root pnpm test = turbo) |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| NOTIFY-03 | Match-Upsert idempotent, update:{} bewahrt notifiedAt |
unit | pnpm --filter api test -- tender-matching.service |
❌ Wave 0 |
| NOTIFY-03 | delta-only: Profil-Anlage bei 2188 Tendern erzeugt 0 un-notified Matches | unit | pnpm --filter api test -- tender-matching.service |
❌ Wave 0 |
| NOTIFY-03 | instant+digest → genau EINE Mail (kein Doppelversand) | unit | pnpm --filter api test -- tender-digest.scheduler |
❌ Wave 0 |
| NOTIFY-01 | Digest-Fälligkeit: daily immer, weekly nur montags; findMany über alle Mandanten |
unit | pnpm --filter api test -- tender-digest.scheduler |
❌ Wave 0 |
| NOTIFY-01 | Zwei Nutzer/zwei Mandanten daily → beide erhalten je eine Mail | unit | pnpm --filter api test -- tender-digest.scheduler |
❌ Wave 0 |
| NOTIFY-02 | Instant nur für instantAlert=true, Bündelung pro Profil/Tick |
unit | pnpm --filter api test -- tender-matching.service |
❌ Wave 0 |
| NOTIFY-04 | TenderMailService nutzt getDecryptedSmtpConfig(tenantId), frischer Transport, close() |
unit (mock nodemailer) | pnpm --filter api test -- tender-mail.service |
❌ Wave 0 |
| NOTIFY-04 | Fehlende SMTP → skip, kein Crash, notifiedAt bleibt null |
unit | pnpm --filter api test -- tender-digest.scheduler |
❌ Wave 0 |
| NOTIFY-01/02 | UI: Digest-Intervall-Select + Instant-Toggle | component | pnpm --filter web test -- SavedSearchBar |
teilweise (SavedSearchBar.test.tsx erweitern) |
Sampling Rate
- Per task commit: betroffene Spec (
pnpm --filter api test -- <spec>) - Per wave merge:
pnpm --filter api test - Phase gate: volle Suite grün (
pnpm test) vor/gsd-verify-work
Wave 0 Gaps
tender-matching.service.spec.ts— matchDelta, delta-only, Idempotenz, Instant-Dispatch (NOTIFY-02/03)tender-digest.scheduler.spec.ts— Fälligkeit, Multi-TenantfindMany, kein Doppelversand (NOTIFY-01/03)tender-mail.service.spec.ts— SMTP-Auflösung, frischer Transport,close(), skip bei fehlender Config (NOTIFY-04) —nodemailermocken wie im DKV-Testtender-notification-pref.service.spec.ts— per-user CRUD, Default 'daily'SavedSearchBar.test.tsxerweitern — Instant-Toggle; Settings-Page-Test für Digest-Intervall
Security Domain
Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | ja | Bestehende Cookie-Auth; alle neuen Routen unter @UseModule('tender-radar') |
| V4 Access Control | ja (kritisch) | Alle Match-/Pref-Reads where:{userId} (IDOR-Schutz, Phase-11-Muster); userId/tenantId NUR aus Auth-Context, nie aus Body/Query |
| V5 Input Validation | ja | class-validator-DTOs für digestInterval (@IsIn(['daily','weekly','off'])) und instantAlert (@IsBoolean) |
| V6 Cryptography | ja | SMTP-Passwort bleibt AES-256-GCM (CalendarCryptoService); entschlüsselt nur im Send-Scope, nie geloggt |
| V7 Errors & Logging | ja | Keine SMTP-Credentials/Empfängerdetails in Logs (DKV-Muster T-07-10) |
Known Threat Patterns for NestJS + Mandanten-SMTP
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| IDOR: fremde Matches/Prefs lesen/ändern | Elevation/Info Disclosure | where:{userId} aus Auth-Context; NotFound statt Forbidden (nicht leaken, ob Ressource existiert) |
| Cross-Tenant-Leak: Nutzer A erhält Matches von B | Info Disclosure | userId-Scoping + korrekte tenantId-SMTP-Auflösung im Digest |
| SMTP-Credential-Leak in Logs | Info Disclosure | Generische Fehlermeldungen, entschlüsseltes Passwort nur im Method-Scope |
| E-Mail-Injection über Profilname/Titel in Betreff/Body | Tampering | nodemailer escaped Header; Body als Text/escaped HTML, keine ungeprüfte HTML-Interpolation von Tender-Titeln |
| Cron-Crash durch einen defekten Mandanten | DoS | Pro-Nutzer catch-and-log, nie den ganzen Lauf abbrechen (Phase-10-Muster) |
Sources
Primary (HIGH confidence — Live-Code + Live-DB, direkt gelesen)
apps/api/src/tenders/tender-query.builder.ts,tender-ingestion.service.ts,tender-scheduler.service.ts,tender-saved-search.service.ts,tender-triage.service.ts,tenders.controller.ts,tenders.module.tsapps/api/src/dkv/dkv-mail.service.ts,dkv-scheduler.service.ts,dkv.module.tsapps/api/src/settings/settings.service.ts,dto/smtp-config.dto.ts;apps/api/src/calendar/crypto.service.ts;apps/api/src/mail/mail.module.tsapps/api/prisma/schema.prisma(Tender, TenderTriage, TenderSavedSearch, SmtpConfig, User, Tenant)apps/web/src/lib/tender-radar-api.ts,components/SavedSearchBar.tsx,settings/page.tsx- Live-DB (
docker compose exec db psql): 2188 Tender, 0 TenderSavedSearch,TenderMatchexistiert nicht apps/api/package.json(Versionen),apps/api/vitest.config.ts,.planning/config.json(nyquist+security = true)
Secondary (MEDIUM confidence — Milestone-Research, teils durch Phase 11 überholt)
.planning/research/ARCHITECTURE.md(Pattern 3–6, TenderMatch-Vorschlag) — Schema-Details durch Phase-11-Realität korrigiert (siehe State of the Art).planning/research/PITFALLS.md(Multi-Tenancy-Pitfalls),.planning/ROADMAP.md§ Phase 12,.planning/REQUIREMENTS.md(NOTIFY-01..04)
Tertiary (LOW confidence)
- keine
Metadata
Confidence breakdown:
- Standard stack: HIGH — keine neuen Pakete, alle Versionen aus
package.jsonverifiziert - Architecture: HIGH — jeder Baustein hat ein direkt gelesenes Repo-Vorbild; Schema gegen Live-DB abgeglichen
- Pitfalls: HIGH — abgeleitet aus dokumentierten Repo-Kommentaren (
DkvSchedulerService-findFirst-Gap, Pitfall-3-Transport) - Assumptions: MEDIUM — 5 offene Punkte (A1–A5), alle niedrig/mittleres Risiko, im Planning bestätigbar
Research date: 2026-07-22 Valid until: 2026-08-21 (stabil — reines Repo-internes Wiring, keine schnelllebigen externen Abhängigkeiten)