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