# 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 (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 wie `DkvMailService`), 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. `TenderMatch` mit `notifiedDigestAt`/`notifiedInstantAt` oder 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.
## 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)`) |
## 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 von `apps/api/package.json` und 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).
```prisma
// 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: `upsert` auf `@@unique([tenderId, savedSearchId])`, `update: {}` (idempotent — bestehendes `notifiedAt` bleibt 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 `notifiedAt` immer VOR dem Digest-Lauf.
**Example (Match-Upsert, idempotent):**
```typescript
// 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:
```typescript
// 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`** (nicht `findFirst`!): `digestInterval='daily'` immer, `digestInterval='weekly'` nur an einem festen Wochentag (z.B. Montag).
- Je Nutzer: `TenderMatch WHERE userId=? AND notifiedAt IS NULL`, nach `savedSearchId` gruppiert (D-02: eine Mail, Abschnitte je Profil).
- Sendet **eine** Mail via `TenderMailService.sendDigest`, dann `updateMany` `notifiedAt=now, channel='digest'` auf alle einbezogenen Match-IDs.
```typescript
// 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.
```typescript
// 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:
```typescript
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
```
- `userEmail` kommt aus `User.email` (verifiziert vorhanden, `@unique`) — im Digest-Cron per `include`/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). `estimatedValue` ist ein String und zu 91,6 % null — nie ungeprüft `Number()`-coercen (Phase-11-Hinweis).
- **Kein globaler Mailer:** `MailService`/`@nestjs-modules/mailer` bleibt unangetastet (System-Mails only, D-08).
### Anti-Patterns to Avoid
- **`findFirst` im Digest-Cron** → bedient nur den ersten Mandanten (exakt der dokumentierte `DkvSchedulerService`-v1-Gap). Immer `findMany` ü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 }` auf `Tender`/Match-Reads by habit** → `Tender` ist global (D-03); Match-Scoping läuft über `userId`.
- **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)
```typescript
// 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)
```typescript
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
1. **Re-Benachrichtigung bei Änderung (`contentHash`)?**
- Was wir wissen: Phase 10 erkennt Änderungen via `contentHash` (SCHEMA-02), aktualisiert aber `pollDueSources` sammelt 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.
2. **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; `digestHour` nur nachrüsten, falls gefordert (A1).
3. **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 -- ` |
| 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 -- `)
- **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-Tenant `findMany`, kein Doppelversand (NOTIFY-01/03)
- [ ] `tender-mail.service.spec.ts` — SMTP-Auflösung, frischer Transport, `close()`, skip bei fehlender Config (NOTIFY-04) — `nodemailer` mocken wie im DKV-Test
- [ ] `tender-notification-pref.service.spec.ts` — per-user CRUD, Default 'daily'
- [ ] `SavedSearchBar.test.tsx` erweitern — 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.ts`
- `apps/api/src/dkv/dkv-mail.service.ts`, `dkv-scheduler.service.ts`, `dkv.module.ts`
- `apps/api/src/settings/settings.service.ts`, `dto/smtp-config.dto.ts`; `apps/api/src/calendar/crypto.service.ts`; `apps/api/src/mail/mail.module.ts`
- `apps/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, `TenderMatch` existiert 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.json` verifiziert
- 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)