Files

41 KiB
Raw Permalink Blame History

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
apps/api/src/tenders/
├── tender-matching.service.ts        # NEU: matchDelta(newTenderIds), Instant-Dispatch
├── tender-mail.service.ts            # NEU: sendInstant/sendDigest (DkvMailService-Klon)
├── tender-digest.scheduler.ts        # NEU: EIN globaler Cron, findMany über fällige Nutzer
├── tender-notification-pref.service.ts # NEU: per-user digestInterval CRUD
├── dto/notification-pref.dto.ts      # NEU
├── tender-ingestion.service.ts       # ERWEITERN: neue Tender-IDs sammeln + matchDelta aufrufen
├── tender-saved-search.service.ts    # ERWEITERN: instantAlert in create/update
└── tenders.controller.ts             # ERWEITERN: Routen für pref + instant-toggle

apps/api/prisma/migrations/
└── 2026072x000000_add_tender_notifications/  # TenderMatch, TenderNotificationPref, instantAlert

apps/web/src/app/(portal)/modules/tender-radar/
├── components/SavedSearchBar.tsx      # ERWEITERN: Instant-Toggle pro Profil
├── settings/page.tsx                  # ERWEITERN: Digest-Intervall-Auswahl (per-user)
└── (lib/tender-radar-api.ts)          # ERWEITERN: neue API-Funktionen

Pattern A: Schema — TenderMatch + per-user Pref + per-profile Flag

What: Drei Schema-Änderungen, alle nach dem Phase-11-Scoping-Muster (userId-Scoping im Service, KEIN forTenant()/RLS; tenantId denormalisiert mitgeführt für spätere Isolation + SMTP-Auflösung).

// NEU — der „getroffen"-Datensatz (D-06). Ein Row je (tender × savedSearch).
model TenderMatch {
  id            String            @id @default(uuid())
  tenderId      String
  savedSearchId String
  userId        String            // denormalisiert (Phase-11-Muster: direktes where:{userId})
  tenantId      String            // denormalisiert — für SMTP-Auflösung im Digest-Cron
  matchedAt     DateTime          @default(now())
  notifiedAt    DateTime?         // NULL = noch nicht benachrichtigt (das Eligibility-Gate)
  notifiedChannel String?         // 'digest' | 'instant' — nur Audit, NICHT Teil der Invariante
  tender        Tender            @relation(fields: [tenderId], references: [id], onDelete: Cascade)
  savedSearch   TenderSavedSearch @relation(fields: [savedSearchId], references: [id], onDelete: Cascade)

  @@unique([tenderId, savedSearchId]) // Upsert-Target — ein Match je Paar, idempotent
  @@index([userId])                   // Digest-Query: where userId, notifiedAt null
  @@index([notifiedAt])
}

// NEU — Digest-Intervall ist per-USER (D-03), nicht per-Profil.
model TenderNotificationPref {
  id             String   @id @default(uuid())
  userId         String   @unique      // ein Pref-Row je Nutzer
  tenantId       String
  digestInterval String   @default("daily") // 'daily' | 'weekly' | 'off' (D-01)
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

  @@index([userId])
}

// ERWEITERN TenderSavedSearch (D-04): Sofort-Alert pro Profil, Default AUS.
model TenderSavedSearch {
  // ... bestehende Felder ...
  instantAlert Boolean @default(false)
  matches      TenderMatch[]   // Gegenrelation
}

When to use: immer. Tender bekommt zusätzlich triage TenderTriage[] schon; jetzt zusätzlich matches TenderMatch[] als Gegenrelation. Rationale notifiedAt (ein Feld): Das Eligibility-Gate ist notifiedAt IS NULL. Sobald irgendein Kanal gesendet hat, ist das Feld gesetzt → das Paar ist raus. Das erfüllt D-06 („nie zweimal — weder Digest UND Sofort, noch zweimal im selben Kanal") direkt. notifiedChannel ist nur Audit.

Pattern B: matched-vs-notified-Invariante (NOTIFY-03 Kern)

What: Die Nicht-Doppelversand-Garantie ist ein Prädikat: WHERE notifiedAt IS NULL.

  • Match-Erzeugung: 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):

// tender-matching.service.ts
await this.prisma.tenderMatch.upsert({
  where: { tenderId_savedSearchId: { tenderId, savedSearchId: search.id } },
  create: {
    tenderId, savedSearchId: search.id,
    userId: search.userId, tenantId: search.tenantId,
    // notifiedAt bleibt null → wird vom nächsten Digest/Instant abgeholt
  },
  update: {}, // Re-Match ändert notifiedAt NICHT → keine Wieder-Benachrichtigung
});

Warum das sicher ist: Selbst wenn matchDelta versehentlich ein bereits gematchtes Paar erneut sieht, bewahrt update: {} das gesetzte notifiedAt. Die Invariante hält strukturell, nicht per Konvention.

Pattern C: Rückstau-Unterdrückung = delta-only Matching (D-07)

What: Matching läuft ausschließlich gegen die in diesem Tick neu angelegten Tender — nie gegen die volle Tabelle. Profil-Erstellung löst keinen Historien-Rescan aus.

Konsequenz: Ein neu angelegtes Profil kann per Konstruktion keine Flut erzeugen — die ~2188 Bestands-Tender wurden in vergangenen Ticks eingelesen und werden nie wieder an matchDelta übergeben. Das Profil sammelt Treffer erst ab dem nächsten Poll-Tick. Damit ist D-07 („nur ab jetzt", konsistent mit Phase-10-D-01 + Day-Cursor-Gate) erfüllt, OHNE ~2188 × N „suppressed rows" schreiben zu müssen.

Wie „neu diesen Tick" ermittelt wird: pollDueSources muss erweitert werden, um die IDs der neu angelegten Tender zu sammeln. Der bestehende upsert meldet nicht create-vs-update. Empfohlener Ansatz — Vorab-Check in der Schleife:

// in der bestehenden Schleife von pollDueSources, VOR dem upsert:
const existing = await this.prisma.tender.findUnique({
  where: { dedupKey: tender.dedupKey }, select: { id: true },
});
// ... upsert wie gehabt ...
const saved = await this.prisma.tender.upsert({ /* ... */ });
if (!existing) newTenderIds.push(saved.id); // nur genuin NEUE Rows

Ein indexierter findUnique je Record (Dutzende/Tag bei DÖE) ist vernachlässigbar. Am Tick-Ende: await this.matching.matchDelta(newTenderIds).

MVP-Vereinfachung: Nur genuin neue Tender lösen Matching/Benachrichtigung aus — contentHash-Änderungen (Fristverlängerung etc.) werden für Phase 12 nicht als Re-Benachrichtigung behandelt. Das sidesteppt die „Notice aktualisiert"-Frage, die CONTEXT nicht fordert. (Offene Frage 1.)

Trade-off / bewusst akzeptiert: Ein Profil, das nach dem Einlesen eines passenden Tenders angelegt wird, „verpasst" diesen Tender in der Benachrichtigung — er ist aber weiterhin normal in der Phase-11-Trefferliste sichtbar. Das ist exakt das gewünschte „nur ab jetzt"-Verhalten, kein Bug.

Pattern D: Digest-Scheduler — EIN globaler Cron (NOTIFY-01)

What: Genau ein platform-weiter @nestjs/schedule-Cron (kein per-Tenant-Job), der nach dem Phase-10-TenderSchedulerService-Muster registriert wird (nicht dem DkvSchedulerService-findFirst-Muster).

  • Läuft z.B. täglich 07:00 Europe/Berlin (0 7 * * *).
  • Selektiert fällige Nutzer per findMany (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.
// tender-digest.scheduler.ts — Fälligkeits-Selektion (Kern)
const weekday = new Date().toLocaleDateString('en-US',
  { timeZone: 'Europe/Berlin', weekday: 'short' }); // 'Mon'...
const dueUsers = await this.prisma.tenderNotificationPref.findMany({
  where: {
    OR: [
      { digestInterval: 'daily' },
      ...(weekday === 'Mon' ? [{ digestInterval: 'weekly' }] : []),
    ],
  },
});
// KEIN findFirst — sonst nur der erste Mandant (DkvScheduler-Pitfall).

Multi-Tenant-Sicherheit: Der Cron ist ein Singleton-Job; er iteriert Nutzer aller Mandanten und löst pro Nutzer über dessen tenantId den SMTP-Transport auf. Kein per-Tenant-Cron, kein findFirst.

Pattern E: Sofort-Alerts (NOTIFY-02)

What: Direkt nach matchDelta im selben Poll-Tick: für Profile mit instantAlert=true die in diesem Tick neu erzeugten (noch notifiedAt IS NULL) Matches je Profil zu einer Sammel-Mail bündeln (D-05), senden, notifiedAt=now, channel='instant' setzen.

// tender-matching.service.ts (Ende von matchDelta)
const instantSearches = savedSearches.filter((s) => s.instantAlert);
for (const search of instantSearches) {
  const fresh = await this.prisma.tenderMatch.findMany({
    where: { savedSearchId: search.id, notifiedAt: null,
             tenderId: { in: newTenderIds } },
    include: { tender: true },
  });
  if (!fresh.length) continue;
  await this.mail.sendInstant(search, fresh.map((m) => m.tender)); // eine Sammel-Mail
  await this.prisma.tenderMatch.updateMany({
    where: { id: { in: fresh.map((m) => m.id) } },
    data: { notifiedAt: new Date(), notifiedChannel: 'instant' },
  });
}

Wichtig: Instant läuft synchron (DKV-Direktaufruf-Stil, kein Message-Broker im Stack). Ein Fehlschlag darf den Tick nicht crashen — catch-and-log pro Profil; bei Sendefehler notifiedAt NICHT setzen → Retry beim nächsten Tick/Digest.

Pattern F: Mandanten-SMTP-Versand (NOTIFY-04)

What: TenderMailService ist ein 1:1-Klon des DkvMailService-Musters:

const smtp = await this.settingsService.getDecryptedSmtpConfig(tenantId);
if (!smtp) { /* log + skip; notifiedAt NICHT setzen → Retry */ return; }
const transport = nodemailer.createTransport({
  host: smtp.host, port: smtp.port,
  secure: smtp.encryption === 'ssl-tls',
  requireTLS: smtp.encryption === 'starttls',
  auth: smtp.username ? { user: smtp.username, pass: smtp.decryptedPassword ?? '' } : undefined,
});
try { await transport.sendMail({ from: smtp.fromAddress, to: userEmail, subject, text, html }); }
finally { transport.close(); } // WR-01: Pool freigeben, sonst Socket-Leck bei Retry
  • 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)

// tender-ingestion.service.ts — am Ende von pollDueSources, nach der Fetch-Schleife:
if (newTenderIds.length) {
  await this.matching.matchDelta(newTenderIds); // erzeugt Matches + feuert Instant
}

Digest-Selektion + eine Mail pro Nutzer (D-02)

for (const pref of dueUsers) {
  const matches = await this.prisma.tenderMatch.findMany({
    where: { userId: pref.userId, notifiedAt: null },
    include: { tender: true, savedSearch: true },
    orderBy: { savedSearch: { name: 'asc' } },
  });
  if (!matches.length) continue;
  const user = await this.prisma.user.findUnique({ where: { id: pref.userId } });
  if (!user) continue;
  // nach Profil gruppieren → eine sektionierte Mail
  await this.mail.sendDigest(user, pref.tenantId, groupByProfile(matches));
  await this.prisma.tenderMatch.updateMany({
    where: { id: { in: matches.map((m) => m.id) } },
    data: { notifiedAt: new Date(), notifiedChannel: 'digest' },
  });
}

State of the Art

Old Approach Current Approach Impact
ARCHITECTURE.md (pre-Phase-11): TenderSavedSearch mit strukturierten Spalten (keywords[], notifyMode, digestHour) Phase 11 shippte filters Json + KEINE notify-Felder Planner MUSS gegen die echte Phase-11-Schema-Realität planen: filters ist ein JSON-Blob, notifyMode/digestHour existieren NICHT. Digest-Intervall wandert auf per-user (TenderNotificationPref, D-03), Instant auf per-profile (instantAlert, D-04)
ARCHITECTURE.md: TenderMatch.notifiedAt (singulär) + „last N days" Backfill Ein notifiedAt bleibt korrekt; Backfill durch delta-only ersetzt (kein „last N days"-Rescan) D-07 wird strukturell statt durch Suppression erfüllt

Deprecated/outdated:

  • ARCHITECTURE.md § „Pattern 4 … Backfill against tenders from the last N days": durch delta-only Matching überholt — kein Backfill nötig. Historische Tender bleiben in der UI sichtbar, lösen aber keine Mail aus.

Assumptions Log

# Claim Section Risk if Wrong
A1 Ein täglicher Digest-Cron um 07:00 Europe/Berlin (weekly montags) genügt D-01 Pattern D Niedrig — D-01 fordert nur daily/weekly/off, keinen per-hour-Zeitpunkt. Falls Nutzer eine feste Uhrzeit wählen wollen, wird TenderNotificationPref um digestHour erweitert.
A2 contentHash-Änderungen lösen in Phase 12 KEINE Re-Benachrichtigung aus (nur genuin neue Tender) Pattern C Mittel — falls „Notice aktualisiert"-Mails gewünscht sind, muss der Delta zusätzlich geänderte IDs führen. CONTEXT fordert es nicht.
A3 Digest-Empfänger-Adresse = User.email Pattern F Niedrig — User.email ist @unique, verifiziert vorhanden.
A4 Fehlende Mandanten-SMTP → skip+log, Match bleibt notifiedAt=null (Retry) Pitfall 6 Niedrig — akzeptables MVP-Verhalten; Alternative wäre ein „dead-letter"-Flag, für MVP unnötig.
A5 Matching läuft am Ende von pollDueSources (kein separater Match-Scheduler) Pattern C Niedrig — CONTEXT nennt beides als Discretion; der Poll-Tick ist der natürliche Delta-Auslöser.

Open Questions (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)