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

537 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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.
</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 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 (RESOLVED)
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.
- **RESOLVED:** Nein — keine Re-Benachrichtigung bei `contentHash`-Änderung. Plan 12-01 sammelt in `pollDueSources` ausschließlich genuin NEUE Tender-IDs (delta-only) und übergibt nur diese an `matchDelta`; geänderte Bestands-Tender lösen keine Mail aus.
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).
- **RESOLVED:** Ein einziger globaler Digest-Cron um 07:00 Europe/Berlin (Plan 12-02); kein per-Nutzer-`digestHour`. Nur das Intervall (daily/weekly/off) ist nutzerkonfigurierbar; `digestHour` bleibt bei Bedarf ein späterer Zusatz.
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.
- **RESOLVED:** Nichts tun — die Matches bleiben stiller `notifiedAt=null`-State (kein Versand, keine Suppression-Markierung). Die Treffer sind weiterhin in der Phase-11-Trefferliste sichtbar; harmlos für MVP.
## 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-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)