docs(12): create phase plan (4 vertical slices, waves 1-3)
This commit is contained in:
+14
-1
@@ -410,9 +410,22 @@ Plans:
|
|||||||
3. Activating a new search profile with many pre-existing historical matches does not flood the user with individual emails -- backfill is suppressed/batched on first activation, and a match already notified once (digest or instant) is never notified again for the same tender+profile pair (explicit matched-vs-notified state)
|
3. Activating a new search profile with many pre-existing historical matches does not flood the user with individual emails -- backfill is suppressed/batched on first activation, and a match already notified once (digest or instant) is never notified again for the same tender+profile pair (explicit matched-vs-notified state)
|
||||||
4. Notification emails are sent through the tenant's own SMTP configuration (reusing the DKV mail pattern), not a shared/global system mailer
|
4. Notification emails are sent through the tenant's own SMTP configuration (reusing the DKV mail pattern), not a shared/global system mailer
|
||||||
|
|
||||||
**Plans**: TBD
|
**Plans**: 4 plans
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
|
- [ ] 12-01-PLAN.md — Schema (TenderMatch/NotificationPref/instantAlert) + delta-only Matching in den Poll-Tick (NOTIFY-03)
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1)*
|
||||||
|
|
||||||
|
- [ ] 12-02-PLAN.md — TenderMailService (mandanten-SMTP) + globaler Digest-Cron (NOTIFY-01/04)
|
||||||
|
|
||||||
|
**Wave 3** *(blocked on Wave 2)*
|
||||||
|
|
||||||
|
- [ ] 12-03-PLAN.md — Sofort-Alerts am Poll-Tick + Ein-Mail-Garantie über beide Kanäle (NOTIFY-02/03)
|
||||||
|
- [ ] 12-04-PLAN.md — Web-UI: Digest-Intervall + Sofort-Alert-Toggle, Pref-Backend (NOTIFY-01/02)
|
||||||
|
|
||||||
### Phase 13: Scraping Adapters & Cross-Source Deduplication
|
### Phase 13: Scraping Adapters & Cross-Source Deduplication
|
||||||
|
|
||||||
**Goal:** The platform expands tender coverage with AI-AG NetServer and cosinex portal adapters and collapses tenders seen on multiple sources into a single entry, while structurally refusing to ever scrape the AGB-prohibited portals.
|
**Goal:** The platform expands tender coverage with AI-AG NetServer and cosinex portal adapters and collapses tenders seen on multiple sources into a single entry, while structurally refusing to ever scrape the AGB-prohibited portals.
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
---
|
||||||
|
phase: 12-tender-notifications
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/prisma/migrations/20260722100000_add_tender_notifications/migration.sql
|
||||||
|
- apps/api/src/tenders/tender-matching.service.ts
|
||||||
|
- apps/api/src/tenders/tender-matching.service.spec.ts
|
||||||
|
- apps/api/src/tenders/tender-ingestion.service.ts
|
||||||
|
- apps/api/src/tenders/tenders.module.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [NOTIFY-03]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Ein neu-in-diesem-Poll-Tick eingelesener Tender, der auf ein aktives Suchprofil passt, erzeugt genau eine TenderMatch-Zeile mit notifiedAt=NULL"
|
||||||
|
- "Das Anlegen eines Suchprofils bei ~2188 Bestands-Tendern erzeugt 0 un-benachrichtigte Matches (delta-only, D-07)"
|
||||||
|
- "Ein erneutes Matchen desselben (tender × savedSearch)-Paares lässt ein bereits gesetztes notifiedAt unangetastet (idempotenter Upsert, D-06)"
|
||||||
|
artifacts:
|
||||||
|
- "apps/api/prisma/schema.prisma (TenderMatch, TenderNotificationPref, TenderSavedSearch.instantAlert)"
|
||||||
|
- "apps/api/prisma/migrations/20260722100000_add_tender_notifications/migration.sql (auf lokale Dev-DB angewendet)"
|
||||||
|
- "apps/api/src/tenders/tender-matching.service.ts (matchDelta)"
|
||||||
|
- "apps/api/src/tenders/tender-matching.service.spec.ts"
|
||||||
|
key_links:
|
||||||
|
- "pollDueSources sammelt die IDs genuin NEUER Tender-Zeilen und ruft am Tick-Ende matchDelta(newTenderIds) auf"
|
||||||
|
- "matchDelta nutzt buildTenderWhere(profile.filters) AND id IN newTenderIds — keine neue Filterlogik"
|
||||||
|
- "TenderMatch.@@unique([tenderId, savedSearchId]) ist das Upsert-Target und garantiert ein Match je Paar"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Schema-Fundament + delta-only Matching-Engine für Phase 12. Legt die drei Datenmodell-Änderungen an (TenderMatch als „getroffen"-Datensatz mit einem einzigen notifiedAt-Gate, TenderNotificationPref als per-user Digest-Intervall, TenderSavedSearch.instantAlert als per-profile Flag) und verdrahtet einen neuen TenderMatchingService in den bestehenden Ingestion-Poll-Tick, sodass ausschließlich die in DIESEM Tick neu eingelesenen Tender gegen die aktiven Suchprofile ausgewertet werden.
|
||||||
|
|
||||||
|
Purpose: NOTIFY-03 Kern-Invariante. Das explizite matched-vs-notified-Datenmodell (D-06) und die strukturelle Rückstau-Unterdrückung durch delta-only Matching (D-07) sind das Fundament, auf dem Digest (12-02) und Instant (12-03) aufsetzen. Ohne diese Zeilen gibt es nichts zu benachrichtigen.
|
||||||
|
|
||||||
|
Output: Migrierte lokale Dev-DB, TenderMatchingService.matchDelta, erweiterter pollDueSources, registrierte Provider.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/phases/12-tender-notifications/12-CONTEXT.md
|
||||||
|
@.planning/phases/12-tender-notifications/12-RESEARCH.md
|
||||||
|
@apps/api/prisma/schema.prisma
|
||||||
|
@apps/api/src/tenders/tender-ingestion.service.ts
|
||||||
|
@apps/api/src/tenders/tender-query.builder.ts
|
||||||
|
@apps/api/src/tenders/tender-saved-search.service.ts
|
||||||
|
@apps/api/src/tenders/tenders.module.ts
|
||||||
|
@apps/api/prisma/migrations/20260721170000_add_tender_saved_search/migration.sql
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Schema-Modelle + handgeschriebene Migration + lokale DB-Anwendung</name>
|
||||||
|
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260722100000_add_tender_notifications/migration.sql</files>
|
||||||
|
<action>
|
||||||
|
Erweitere schema.prisma um drei Änderungen nach dem exakten Phase-11-Scoping-Muster (userId-Scoping im Service, KEIN forTenant()/RLS; tenantId denormalisiert mitgeführt für spätere Isolation + SMTP-Auflösung), wie in RESEARCH.md Pattern A spezifiziert:
|
||||||
|
|
||||||
|
(a) Neues Model TenderMatch — der „getroffen"-Datensatz (D-06). Felder: id (uuid pk), tenderId, savedSearchId, userId (denormalisiert), tenantId (denormalisiert), matchedAt (DateTime default now), notifiedAt (DateTime? — NULL ist das Eligibility-Gate), notifiedChannel (String? — nur Audit 'digest'/'instant', NICHT Teil der Invariante). Relationen: tender (FK Tender, onDelete Cascade), savedSearch (FK TenderSavedSearch, onDelete Cascade). Constraints: @@unique([tenderId, savedSearchId]) als Upsert-Target, @@index([userId]), @@index([notifiedAt]). EIN einziges notifiedAt-Feld verwenden — NICHT zwei getrennte Spalten (RESEARCH.md Alternatives Considered: zwei Spalten verkomplizieren die Invariante und suggerieren fälschlich Doppelversand).
|
||||||
|
|
||||||
|
(b) Neues Model TenderNotificationPref — per-user Digest-Intervall (D-03): id (uuid pk), userId (String @unique — ein Row je Nutzer), tenantId, digestInterval (String @default("daily") — Werte 'daily'|'weekly'|'off' gemäß D-01), createdAt, updatedAt, @@index([userId]).
|
||||||
|
|
||||||
|
(c) Erweitere bestehendes Model TenderSavedSearch (D-04): Feld instantAlert Boolean @default(false) — Sofort-Alert pro Profil, Default AUS; plus Gegenrelation matches TenderMatch[].
|
||||||
|
|
||||||
|
(d) Erweitere Model Tender um die Gegenrelation matches TenderMatch[] (analog zur bestehenden triage TenderTriage[]-Relation).
|
||||||
|
|
||||||
|
Schreibe die Migration von Hand als apps/api/prisma/migrations/20260722100000_add_tender_notifications/migration.sql — Muster exakt wie 20260721170000_add_tender_saved_search/migration.sql (CREATE TABLE IF NOT EXISTS, ALTER TABLE ADD COLUMN für instantAlert mit DEFAULT false, CREATE UNIQUE INDEX / CREATE INDEX IF NOT EXISTS, FK-Constraints mit ON DELETE CASCADE). Kommentiere die matched-vs-notified-Invariante und das userId-Scoping inline.
|
||||||
|
|
||||||
|
Wende die Migration auf die LOKALE Dev-DB an (kein Docker-Deploy Testserver) via `docker compose exec -T db psql -U tessera -d tessera_dev -f -` mit dem Migrations-SQL, oder gleichwertig `pnpm --filter api prisma migrate deploy`. Danach `pnpm --filter api prisma generate`, damit der Prisma-Client die neuen Modelle kennt.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && npx prisma validate && npx prisma generate && docker compose -f ../../docker-compose.yml exec -T db psql -U tessera -d tessera_dev -c "SELECT to_regclass('public.\"TenderMatch\"'), to_regclass('public.\"TenderNotificationPref\"');" | grep -q TenderMatch</automated>
|
||||||
|
</verify>
|
||||||
|
<done>schema.prisma enthält TenderMatch (mit @@unique([tenderId,savedSearchId]) und EINEM notifiedAt), TenderNotificationPref, TenderSavedSearch.instantAlert; die Tabellen existieren in der lokalen Dev-DB; prisma generate läuft fehlerfrei.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: TenderMatchingService.matchDelta — delta-only, idempotent (RED→GREEN)</name>
|
||||||
|
<files>apps/api/src/tenders/tender-matching.service.ts, apps/api/src/tenders/tender-matching.service.spec.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1 (delta-only, D-07): Bei 3 aktiven Profilen und einer newTenderIds-Liste von 2 IDs wird buildTenderWhere je Profil UND mit id IN newTenderIds aufgerufen; niemals eine ungefilterte Query über die volle Tender-Tabelle. Simulierte 2188 Bestands-Tender, die NICHT in newTenderIds sind, erzeugen 0 Matches.
|
||||||
|
- Test 2 (Match-Erzeugung): Ein Tender, der laut buildTenderWhere zu Profil P passt, führt zu genau einem tenderMatch.upsert mit create-Daten { tenderId, savedSearchId: P.id, userId: P.userId, tenantId: P.tenantId } und notifiedAt undefined (bleibt NULL).
|
||||||
|
- Test 3 (Idempotenz, D-06): Der Upsert verwendet update:{} — ein erneuter matchDelta-Lauf über dasselbe Paar überschreibt ein bereits gesetztes notifiedAt NICHT.
|
||||||
|
- Test 4 (leeres Delta): matchDelta([]) macht nichts (kein DB-Zugriff, kein Fehler).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Schreibe ZUERST tender-matching.service.spec.ts (RED) gemäß behavior-Block, dann implementiere tender-matching.service.ts (GREEN). Muster für Service-Struktur + Prisma-Mock aus tender-triage.service.spec.ts / tender-ingestion.service.spec.ts übernehmen.
|
||||||
|
|
||||||
|
TenderMatchingService (@Injectable) mit constructor(prisma: PrismaService). Öffentliche Methode matchDelta(newTenderIds: string[]): Promise<void>: bei leerem Array sofort return. Lade alle aktiven Suchprofile via prisma.tenderSavedSearch.findMany() (alle Profile aller Nutzer — Tender-Katalog ist global D-03). Für jedes Profil: baue where = buildTenderWhere(profile.filters as unknown as TenderQueryDto) aus tender-query.builder.ts (Wiederverwendung der getesteten Phase-11-Filterlogik — Don't Hand-Roll), kombiniere mit { id: { in: newTenderIds } } (delta-only Grenze — das ist die strukturelle Rückstau-Unterdrückung D-07). Selektiere passende Tender-IDs via prisma.tender.findMany({ where: { AND: [where, { id: { in: newTenderIds } }] }, select: { id: true } }). Für jede Treffer-ID: prisma.tenderMatch.upsert auf where { tenderId_savedSearchId: { tenderId, savedSearchId: profile.id } }, create { tenderId, savedSearchId, userId: profile.userId, tenantId: profile.tenantId } (notifiedAt bleibt NULL), update {} (idempotent — bewahrt notifiedAt bei Re-Match, D-06).
|
||||||
|
|
||||||
|
WICHTIG: In dieser Wave erzeugt matchDelta NUR Match-Zeilen — noch KEIN Instant-Versand (das kommt in 12-03, das diese Datei erweitert). Kein TenderMailService-Import hier.
|
||||||
|
|
||||||
|
Beachte: filters ist Json und muss als TenderQueryDto gecastet werden; estimatedValue/deadlineAt-Nullhandling ist bereits in buildTenderWhere gelöst — nicht duplizieren.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter api test -- tender-matching.service</automated>
|
||||||
|
</verify>
|
||||||
|
<done>tender-matching.service.spec.ts deckt delta-only, Match-Erzeugung, Idempotenz und leeres Delta ab und ist grün; matchDelta ruft buildTenderWhere je Profil auf und upsertet auf @@unique([tenderId,savedSearchId]) mit update:{}.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: pollDueSources sammelt neue Tender-IDs + ruft matchDelta; Provider-Registrierung</name>
|
||||||
|
<files>apps/api/src/tenders/tender-ingestion.service.ts, apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/tender-ingestion.service.spec.ts</files>
|
||||||
|
<action>
|
||||||
|
Erweitere TenderIngestionService.pollDueSources (RESEARCH.md Pattern C / Code Examples): Führe innerhalb der Tender-Verarbeitungsschleife eine lokale Sammelliste newTenderIds ein. VOR dem bestehenden tender.upsert je Record einen indexierten Vorab-Check ausführen: prisma.tender.findUnique({ where: { dedupKey: tender.dedupKey }, select: { id: true } }); den bestehenden upsert unverändert lassen und dessen Rückgabe-id verwenden; wenn der Vorab-Check null lieferte (genuin NEUE Zeile), saved.id an newTenderIds pushen. Der Vorab-findUnique ist indexiert (dedupKey @unique) und bei Dutzenden Records/Tag vernachlässigbar. Am Tick-Ende — nach der while-Schleife und VOR/parallel zu pruneExpiredTenders, aber innerhalb des try-Blocks — falls newTenderIds.length: await this.matching.matchDelta(newTenderIds). Der bestehende catch-and-log-Rahmen bleibt: ein Matching-Fehler darf den Tick nicht crashen.
|
||||||
|
|
||||||
|
Injiziere TenderMatchingService in den TenderIngestionService-Konstruktor. Erweitere den bestehenden Spec tender-ingestion.service.spec.ts um einen Test: nach einem Tick mit 2 neuen + 1 bestehenden Record wird matching.matchDelta genau mit den 2 neuen IDs aufgerufen (bestehende Record-ID nicht enthalten) — delta-only am Auslösepunkt.
|
||||||
|
|
||||||
|
Registriere TenderMatchingService als Provider in tenders.module.ts (providers-Array, neben den bestehenden). Aktualisiere den Modul-Doc-Kommentar um den Plan-12-01-Beitrag.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter api test -- tender-ingestion.service && grep -q "TenderMatchingService" apps/api/src/tenders/tenders.module.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>pollDueSources sammelt genuin neue Tender-IDs und ruft matchDelta nur mit diesen auf; TenderMatchingService ist im Modul registriert und in TenderIngestionService injiziert; der erweiterte Ingestion-Spec ist grün.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Poll-Tick (Server-intern) → DB | Vom Upstream normalisierte Tender-Daten kreuzen in TenderMatch-Zeilen; kein direkter User-Input, aber Profil-`filters` (user-erstellt) steuern die where-Query |
|
||||||
|
| Suchprofil-`filters` (user-erstellt) → Prisma-where | Das JSON eines Nutzers wird zu einer DB-Query kompiliert |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-12-01 | Tampering | matchDelta buildTenderWhere(filters) | medium | mitigate | filters wird ausschließlich über die getestete buildTenderWhere-Whitelist zu parametrisierten Prisma-Filtern; kein Raw-SQL, keine String-Konkatenation (Phase-11-T-11-01-Muster) |
|
||||||
|
| T-12-02 | Denial of Service | matchDelta über volle Tabelle | high | mitigate | delta-only Grenze `id IN newTenderIds` verhindert Full-Table-Scan/Rückstau-Flut (D-07); Bestands-Tender werden nie an matchDelta übergeben |
|
||||||
|
| T-12-03 | Information Disclosure | TenderMatch userId/tenantId Denormalisierung | medium | mitigate | userId/tenantId werden aus dem Profil-Datensatz (server-seitig) kopiert, nie aus Request-Input; Reads erfolgen erst in 12-02/12-04 strikt where:{userId} |
|
||||||
|
| T-12-04 | Elevation of Privilege | Idempotenter Upsert vs. Re-Notify | high | mitigate | update:{} bewahrt notifiedAt — ein Re-Match kann ein bereits benachrichtigtes Paar strukturell nicht zurücksetzen (D-06) |
|
||||||
|
| T-12-SC | Tampering | npm/pip/cargo installs | high | accept | Diese Phase installiert KEINE Pakete (RESEARCH Package Legitimacy Audit: keine Neuinstallation) — kein Slopcheck nötig |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter api test -- tender-matching.service` grün (delta-only, Idempotenz, Match-Erzeugung)
|
||||||
|
- `pnpm --filter api test -- tender-ingestion.service` grün (matchDelta nur mit neuen IDs)
|
||||||
|
- `docker compose exec -T db psql`-Check: TenderMatch + TenderNotificationPref existieren in der Dev-DB
|
||||||
|
- `npx prisma validate` fehlerfrei
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Ein neuer, passender Tender erzeugt genau eine TenderMatch-Zeile mit notifiedAt=NULL
|
||||||
|
- Ein neu angelegtes Profil erzeugt bei ~2188 Bestands-Tendern 0 un-benachrichtigte Matches (delta-only)
|
||||||
|
- Re-Match bewahrt ein gesetztes notifiedAt (idempotenter Upsert)
|
||||||
|
- Migration ist auf der lokalen Dev-DB angewendet, Prisma-Client kennt die neuen Modelle
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/12-tender-notifications/12-01-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
---
|
||||||
|
phase: 12-tender-notifications
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: [12-01]
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/tenders/tender-mail.service.ts
|
||||||
|
- apps/api/src/tenders/tender-mail.service.spec.ts
|
||||||
|
- apps/api/src/tenders/tender-digest.scheduler.ts
|
||||||
|
- apps/api/src/tenders/tender-digest.scheduler.spec.ts
|
||||||
|
- apps/api/src/tenders/tenders.module.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [NOTIFY-01, NOTIFY-04, NOTIFY-03]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Ein Nutzer mit digestInterval=daily (oder ohne Pref-Zeile — Default daily, D-01) und mindestens einem notifiedAt=NULL-Match erhält beim Digest-Lauf genau eine E-Mail, nach Suchprofil gegliedert (D-02)"
|
||||||
|
- "Zwei Nutzer in zwei Mandanten mit daily erhalten je eine eigene E-Mail über ihre jeweilige mandanten-SMTP-Konfiguration (findMany, kein findFirst)"
|
||||||
|
- "Nach erfolgreichem Digest-Versand tragen alle einbezogenen Matches notifiedAt=now und notifiedChannel='digest' — beim nächsten Lauf werden sie nicht erneut versendet (D-06)"
|
||||||
|
- "Ein Mandant ohne SmtpConfig führt zu Skip+Log für dessen Nutzer, notifiedAt bleibt NULL, der Lauf bricht für andere Nutzer NICHT ab"
|
||||||
|
artifacts:
|
||||||
|
- "apps/api/src/tenders/tender-mail.service.ts (sendDigest/sendInstant, DkvMailService-Klon)"
|
||||||
|
- "apps/api/src/tenders/tender-digest.scheduler.ts (ein globaler Cron)"
|
||||||
|
- "apps/api/src/tenders/tender-mail.service.spec.ts"
|
||||||
|
- "apps/api/src/tenders/tender-digest.scheduler.spec.ts"
|
||||||
|
key_links:
|
||||||
|
- "TenderMailService ruft SettingsService.getDecryptedSmtpConfig(tenantId) je Send und erzeugt einen frischen nodemailer-Transport, transport.close() im finally (D-08)"
|
||||||
|
- "Der Digest-Cron selektiert fällige Nutzer via findMany und je Nutzer TenderMatch WHERE userId=? AND notifiedAt IS NULL"
|
||||||
|
- "TendersModule importiert SettingsModule, damit TenderMailService SettingsService injizieren kann"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Der Zustell-Kanal „periodischer Digest" plus der mandanten-SMTP-Versandpfad. Baut TenderMailService (1:1-Klon des DkvMailService-Musters: frischer nodemailer-Transport pro Send über die mandantenspezifische SmtpConfig, transport.close() im finally) und einen EINZIGEN globalen Digest-Cron, der über alle fälligen Nutzer aller Mandanten iteriert, je Nutzer die un-benachrichtigten Matches nach Suchprofil gruppiert, genau eine sektionierte Mail sendet und die Matches als benachrichtigt stempelt.
|
||||||
|
|
||||||
|
Purpose: NOTIFY-01 (konfigurierbarer Digest) + NOTIFY-04 (mandanten-SMTP) + der Digest-Hälfte von NOTIFY-03 (kein Doppelversand durch das notifiedAt-IS-NULL-Gate). Nach diesem Plan bekommt ein Nutzer proaktiv E-Mails über neue Treffer.
|
||||||
|
|
||||||
|
Output: TenderMailService, TenderDigestScheduler (global), registrierte Provider, SettingsModule-Import.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/phases/12-tender-notifications/12-CONTEXT.md
|
||||||
|
@.planning/phases/12-tender-notifications/12-RESEARCH.md
|
||||||
|
@apps/api/src/dkv/dkv-mail.service.ts
|
||||||
|
@apps/api/src/settings/settings.service.ts
|
||||||
|
@apps/api/src/settings/settings.module.ts
|
||||||
|
@apps/api/src/tenders/tender-scheduler.service.ts
|
||||||
|
@apps/api/src/tenders/tenders.module.ts
|
||||||
|
@apps/api/prisma/schema.prisma
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: TenderMailService — mandanten-SMTP-Versand (DkvMailService-Klon)</name>
|
||||||
|
<files>apps/api/src/tenders/tender-mail.service.ts, apps/api/src/tenders/tender-mail.service.spec.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1 (D-08 SMTP-Auflösung): sendDigest ruft settingsService.getDecryptedSmtpConfig(tenantId) mit exakt der übergebenen tenantId; der nodemailer-Transport wird mit host/port/secure(ssl-tls)/requireTLS(starttls)/auth aus dieser Config gebaut.
|
||||||
|
- Test 2 (frischer Transport + close): nodemailer.createTransport wird pro Send genau einmal aufgerufen; transport.close() wird im finally aufgerufen (auch bei sendMail-Fehler) — WR-01 Socket-Leck-Schutz.
|
||||||
|
- Test 3 (fehlende Config): getDecryptedSmtpConfig=null → sendDigest/sendInstant sendet nichts, wirft NICHT, gibt einen falsy/„skipped"-Indikator zurück (Aufrufer setzt dann notifiedAt nicht).
|
||||||
|
- Test 4 (Betreff/Body): sendDigest baut EINE Mail an user.email, sektioniert nach Profilname; sendInstant baut EINE Mail für ein Profil mit seiner Tender-Liste. estimatedValue (String, meist null) wird nie ungeprüft Number()-coerct.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Schreibe ZUERST tender-mail.service.spec.ts (RED) — nodemailer wird gemockt wie im DkvMailService-Vorbild (vi.mock('nodemailer') mit createTransport → { sendMail, close }). Dann implementiere tender-mail.service.ts (GREEN) als strukturellen Klon von dkv-mail.service.ts.
|
||||||
|
|
||||||
|
TenderMailService (@Injectable) mit constructor(settingsService: SettingsService). Private Helper buildTransport-über-tenantId, der getDecryptedSmtpConfig(tenantId) lädt; bei null → generisches Log + return „skipped" (Signal an den Aufrufer, notifiedAt NICHT zu setzen → Retry beim nächsten Lauf, RESEARCH Pitfall 6). Transport exakt nach DkvMailService bauen: secure = encryption==='ssl-tls', requireTLS = encryption==='starttls', auth nur wenn username gesetzt, pass = decryptedPassword ?? ''. sendMail im try, transport.close() im finally.
|
||||||
|
|
||||||
|
Öffentliche Methoden:
|
||||||
|
- sendDigest(user: { email; ... }, tenantId, sections): sections ist die nach Profil gruppierte Trefferstruktur (Profilname → Tender[]). Baut EINE Mail (D-02): Betreff z.B. „Ausschreibungs-Radar: neue Treffer", Body je Profil eine Überschrift + Liste (Titel, buyerName, deadlineAt, estimatedValue nur wenn nicht null, sourceUrl-Link). Schlichtes hardcodiertes Deutsch, Text + optional HTML (i18n = Phase 14).
|
||||||
|
- sendInstant(search: { name; ... }, tenders: Tender[]): EINE Sammel-Mail für ein Profil (D-05) mit derselben Zeilenformatierung. (Von 12-03 aufgerufen; hier bereits implementieren, damit 12-03 nur noch verdrahtet.)
|
||||||
|
|
||||||
|
Security: entschlüsseltes Passwort nur im Method-Scope, nie loggen (T-07-10-Muster); generische Fehlermeldungen. Body als escaped/Text — Tender-Titel/Profilname nie ungeprüft in HTML interpolieren (E-Mail-Injection-Schutz).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter api test -- tender-mail.service</automated>
|
||||||
|
</verify>
|
||||||
|
<done>tender-mail.service.spec.ts grün: getDecryptedSmtpConfig je Send, frischer Transport + close() im finally, Skip bei fehlender Config ohne Throw, sektionierter Digest-Body; kein globaler Mailer verwendet.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: TenderDigestScheduler — ein globaler Cron, findMany über fällige Nutzer</name>
|
||||||
|
<files>apps/api/src/tenders/tender-digest.scheduler.ts, apps/api/src/tenders/tender-digest.scheduler.spec.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1 (Fälligkeit D-01): daily-Nutzer sind an jedem Lauf fällig; weekly-Nutzer nur an einem festen Wochentag (Montag Europe/Berlin); off-Nutzer nie. Ein Nutzer OHNE Pref-Zeile wird wie daily behandelt (Default D-01).
|
||||||
|
- Test 2 (Multi-Tenant, findMany): Zwei Nutzer in zwei verschiedenen Mandanten, beide daily, beide mit un-benachrichtigten Matches → beide erhalten je einen sendDigest-Aufruf mit ihrer jeweiligen tenantId. Es wird NIE nur der erste Nutzer bedient.
|
||||||
|
- Test 3 (Gruppierung + eine Mail, D-02): Ein Nutzer mit Matches aus 2 Profilen → genau ein sendDigest-Aufruf, dessen sections beide Profil-Abschnitte enthält.
|
||||||
|
- Test 4 (kein Doppelversand, D-06): Nur Matches mit notifiedAt=NULL werden selektiert; nach erfolgreichem Send updateMany notifiedAt=now, notifiedChannel='digest' auf genau diese Match-IDs. Ein bereits per instant benachrichtigter Match (notifiedAt gesetzt) wird nie einbezogen.
|
||||||
|
- Test 5 (Robustheit): sendDigest-Fehler / fehlende SMTP für einen Nutzer → dessen Matches bleiben notifiedAt=NULL, der Lauf fährt mit den übrigen Nutzern fort (kein Cron-Crash).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Schreibe ZUERST tender-digest.scheduler.spec.ts (RED), dann implementiere tender-digest.scheduler.ts (GREEN). Registrierungs- und CronJob-Mechanik verbatim aus tender-scheduler.service.ts übernehmen (require('cron').CronJob-Workaround, SchedulerRegistry.addCronJob, JOB_NAME='tender-digest', OnModuleInit).
|
||||||
|
|
||||||
|
TenderDigestScheduler (@Injectable, implements OnModuleInit) mit constructor(schedulerRegistry: SchedulerRegistry, prisma: PrismaService, mail: TenderMailService). onModuleInit registriert EINEN globalen Cron (z.B. '0 7 * * *', täglich 07:00 — ein einziger platform-weiter Job, KEINE Tenant-Dimension, exakt das TenderSchedulerService-Muster, NICHT das DkvSchedulerService-Einzelmandanten-Muster).
|
||||||
|
|
||||||
|
Kern-Methode runDigest(): Ermittle die Kandidaten-Nutzer als distinct userId aus prisma.tenderMatch.findMany/groupBy where { notifiedAt: null } — nur Nutzer mit offenen Treffern. Lade je Kandidat dessen Pref via prisma.tenderNotificationPref.findUnique({ where: { userId } }); fehlt die Zeile → als 'daily' behandeln (Default D-01). Bestimme Fälligkeit: daily=immer, weekly=nur wenn Europe/Berlin-Wochentag Montag, off=skip. WICHTIG: über ALLE fälligen Nutzer iterieren (findMany-Semantik) — den Einzelmandanten-Einstieg (findFirst) NICHT verwenden; das ist der dokumentierte DkvScheduler-v1-Gap (RESEARCH Pitfall 1). Je fälligem Nutzer: matches = prisma.tenderMatch.findMany({ where: { userId, notifiedAt: null }, include: { tender: true, savedSearch: true }, orderBy }); bei leer weiter. Lade user = prisma.user.findUnique({ where: { id: userId } }) für email + tenantId (bevorzugt user.tenantId für die SMTP-Auflösung). Gruppiere nach savedSearch (D-02), rufe mail.sendDigest(user, user.tenantId, sections). NUR bei erfolgreichem (nicht-„skipped") Send: prisma.tenderMatch.updateMany({ where: { id: { in: [...] } }, data: { notifiedAt: new Date(), notifiedChannel: 'digest' } }). Pro Nutzer try/catch-and-log — ein Fehler bricht den Gesamtlauf nicht ab (RESEARCH Pitfall 6).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter api test -- tender-digest.scheduler</automated>
|
||||||
|
</verify>
|
||||||
|
<done>tender-digest.scheduler.spec.ts grün: Fälligkeit daily/weekly/off + Default-daily, Multi-Tenant über findMany, eine sektionierte Mail je Nutzer, notifiedAt-Stempelung nur nach Erfolg, Robustheit bei SMTP-Fehler.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Provider-Registrierung + SettingsModule-Import</name>
|
||||||
|
<files>apps/api/src/tenders/tenders.module.ts</files>
|
||||||
|
<action>
|
||||||
|
Erweitere tenders.module.ts: importiere SettingsModule (aus ../settings/settings.module — es exportiert SettingsService, siehe DkvModule-Vorbild), damit TenderMailService SettingsService injizieren kann. Füge TenderMailService und TenderDigestScheduler dem providers-Array hinzu (neben den bestehenden inkl. dem in 12-01 hinzugefügten TenderMatchingService). Aktualisiere den Modul-Doc-Kommentar um den Plan-12-02-Beitrag (Digest-Kanal + mandanten-SMTP). ScheduleModule.forRoot() ist bereits global in AppModule registriert — nicht erneut importieren.
|
||||||
|
|
||||||
|
Verifiziere per Test-Build, dass die DI-Graph auflösbar ist (TenderDigestScheduler → TenderMailService → SettingsService).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && npx tsc --noEmit -p tsconfig.json && grep -q "SettingsModule" src/tenders/tenders.module.ts && grep -q "TenderDigestScheduler" src/tenders/tenders.module.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>TendersModule importiert SettingsModule und registriert TenderMailService + TenderDigestScheduler; tsc --noEmit ist fehlerfrei (DI-Graph auflösbar).</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Digest-Cron (Server) → mandanten-SMTP-Relay | Entschlüsselte SMTP-Credentials + Empfängeradressen verlassen den Prozess Richtung externem Mailserver |
|
||||||
|
| DB (SmtpConfig, verschlüsselt) → TenderMailService | AES-256-GCM-Passwort wird nur im Send-Scope entschlüsselt |
|
||||||
|
| TenderMatch (per-user) → Digest-Mail | Trefferdaten eines Nutzers dürfen nur an genau diesen Nutzer gehen |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-12-05 | Information Disclosure | Cross-Tenant-Digest | high | mitigate | Digest-Query strikt where:{userId}; SMTP je Nutzer über dessen tenantId; findMany über alle Nutzer statt findFirst (Pitfall 1) — kein Cross-User/Cross-Tenant-Leak |
|
||||||
|
| T-12-06 | Information Disclosure | SMTP-Credential-Leak in Logs | high | mitigate | decryptedPassword nur im Method-Scope, nie geloggt; generische Fehlermeldungen (DkvMailService T-07-10-Muster) |
|
||||||
|
| T-12-07 | Denial of Service | Cron-Crash durch einen defekten Mandanten | high | mitigate | Pro-Nutzer try/catch-and-log; fehlende SmtpConfig → Skip+notifiedAt bleibt NULL; nie den ganzen Lauf abbrechen (Pitfall 6) |
|
||||||
|
| T-12-08 | Tampering | E-Mail-Injection über Tender-Titel/Profilname | medium | mitigate | nodemailer escaped Header; Body als Text/escaped HTML, keine ungeprüfte HTML-Interpolation von Tender-Feldern |
|
||||||
|
| T-12-09 | Elevation of Privilege | Doppelversand-Umgehung des notifiedAt-Gates | high | mitigate | Digest selektiert ausschließlich notifiedAt IS NULL; Stempelung nur nach erfolgreichem Send → strukturell kein Doppelversand (D-06) |
|
||||||
|
| T-12-SC | Tampering | npm/pip/cargo installs | high | accept | Keine Paketinstallation (RESEARCH Package Legitimacy Audit: keine Neuinstallation) |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter api test -- tender-mail.service` grün
|
||||||
|
- `pnpm --filter api test -- tender-digest.scheduler` grün
|
||||||
|
- `npx tsc --noEmit` fehlerfrei (DI-Graph auflösbar)
|
||||||
|
- Manuell/UAT (end-of-phase): Digest-Testlauf gegen lokales Mailhog (localhost:1025) zeigt genau eine sektionierte Mail je Nutzer
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Nutzer mit offenen Matches und daily/Default erhält beim Digest-Lauf genau eine nach Profil gegliederte Mail über seine mandanten-SMTP
|
||||||
|
- Zwei Nutzer/zwei Mandanten daily → beide erhalten je eine Mail (findMany, kein findFirst)
|
||||||
|
- Nach Versand tragen die Matches notifiedAt + channel='digest'; kein erneuter Versand
|
||||||
|
- Fehlende SMTP eines Mandanten bricht den Lauf nicht ab
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/12-tender-notifications/12-02-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
---
|
||||||
|
phase: 12-tender-notifications
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on: [12-01, 12-02]
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/tenders/tender-matching.service.ts
|
||||||
|
- apps/api/src/tenders/tender-matching.service.spec.ts
|
||||||
|
- apps/api/src/tenders/tender-notifications.integration.spec.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [NOTIFY-02, NOTIFY-03]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Wird im Poll-Tick ein neuer Treffer gegen ein Profil mit instantAlert=true erzeugt, erhält der Nutzer kurz darauf eine Sofort-E-Mail für dieses Profil (D-04/D-05)"
|
||||||
|
- "Mehrere im selben Tick neu eingelesene Treffer desselben Profils werden zu EINER Sammel-Sofort-Mail gebündelt (D-05)"
|
||||||
|
- "Ein Profil mit instantAlert=false löst nie eine Sofort-Mail aus"
|
||||||
|
- "Ein per Sofort-Alert benachrichtigtes Paar (notifiedAt gesetzt, channel='instant') taucht nie zusätzlich im Digest auf — insgesamt genau EINE Mail (D-06)"
|
||||||
|
artifacts:
|
||||||
|
- "apps/api/src/tenders/tender-matching.service.ts (Instant-Dispatch am Ende von matchDelta)"
|
||||||
|
- "apps/api/src/tenders/tender-notifications.integration.spec.ts (instant+digest = genau eine Mail)"
|
||||||
|
key_links:
|
||||||
|
- "matchDelta filtert nach Match-Erzeugung die Profile mit instantAlert=true und lädt je Profil dessen frische notifiedAt=NULL-Matches dieses Ticks"
|
||||||
|
- "Nach erfolgreichem sendInstant setzt matchDelta notifiedAt=now, notifiedChannel='instant' — dasselbe Gate wie der Digest liest"
|
||||||
|
- "TenderMailService (aus 12-02) wird in TenderMatchingService injiziert"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Der Zustell-Kanal „Sofort-Alert". Erweitert die in 12-01 gebaute matchDelta um einen synchronen Instant-Dispatch am Poll-Tick: für Profile mit instantAlert=true werden die in diesem Tick frisch erzeugten (notifiedAt=NULL) Treffer je Profil zu einer Sammel-Mail gebündelt (D-05), über TenderMailService.sendInstant (aus 12-02) versendet und sofort als benachrichtigt gestempelt — dasselbe notifiedAt-Gate, das der Digest liest, sodass ein per Instant versendetes Paar strukturell nie zusätzlich im Digest landet.
|
||||||
|
|
||||||
|
Purpose: NOTIFY-02 (optionaler Sofort-Alert pro Profil) + die Instant-Hälfte der NOTIFY-03-Invariante (kein Doppelversand Instant+Digest). Nach diesem Plan bekommen Nutzer, die ein Profil bewusst auf „sofort" gestellt haben, unmittelbar nach dem Einlesen eine Mail.
|
||||||
|
|
||||||
|
Output: erweiterte matchDelta mit Instant-Dispatch, erweiterter Matching-Spec, ein Integrations-Spec, der die Ein-Mail-Garantie über beide Kanäle beweist.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/phases/12-tender-notifications/12-CONTEXT.md
|
||||||
|
@.planning/phases/12-tender-notifications/12-RESEARCH.md
|
||||||
|
@apps/api/src/tenders/tender-matching.service.ts
|
||||||
|
@apps/api/src/tenders/tender-mail.service.ts
|
||||||
|
@apps/api/src/tenders/tender-digest.scheduler.ts
|
||||||
|
@apps/api/prisma/schema.prisma
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Instant-Dispatch in matchDelta (RED→GREEN)</name>
|
||||||
|
<files>apps/api/src/tenders/tender-matching.service.ts, apps/api/src/tenders/tender-matching.service.spec.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1 (nur instantAlert=true, D-04): Zwei Profile matchen einen neuen Tender; nur das Profil mit instantAlert=true führt zu einem sendInstant-Aufruf, das mit instantAlert=false nicht.
|
||||||
|
- Test 2 (Bündelung pro Profil/Tick, D-05): Drei im selben matchDelta neu erzeugte Treffer eines instantAlert-Profils führen zu genau EINEM sendInstant-Aufruf mit allen drei Tendern, nicht drei Einzelmails.
|
||||||
|
- Test 3 (Stempelung nach Erfolg, D-06): Nach erfolgreichem sendInstant tragen genau die versendeten Matches notifiedAt=now, notifiedChannel='instant'.
|
||||||
|
- Test 4 (Retry-Sicherheit): sendInstant wirft/„skipped" → notifiedAt bleibt NULL für dieses Profil, der restliche Tick (andere Profile) läuft weiter (catch-and-log pro Profil).
|
||||||
|
- Test 5 (nichts Neues): Ein instantAlert-Profil ohne frische notifiedAt=NULL-Treffer dieses Ticks löst keinen sendInstant-Aufruf aus.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Erweitere tender-matching.service.spec.ts ZUERST um die behavior-Tests (RED), dann implementiere den Dispatch in tender-matching.service.ts (GREEN). Injiziere TenderMailService (aus 12-02) in den TenderMatchingService-Konstruktor (Provider ist bereits im Modul registriert; nur Konstruktor-Parameter ergänzen — keine tenders.module.ts-Änderung nötig).
|
||||||
|
|
||||||
|
Am Ende von matchDelta, NACHDEM alle Match-Upserts dieses Ticks geschrieben wurden (RESEARCH Pattern E): filtere die geladenen Suchprofile auf instantAlert===true. Für jedes solche Profil: fresh = prisma.tenderMatch.findMany({ where: { savedSearchId: profile.id, notifiedAt: null, tenderId: { in: newTenderIds } }, include: { tender: true } }); bei leer weiter. Rufe mail.sendInstant(profile, fresh.map(m => m.tender)) — EINE Sammel-Mail (D-05). NUR bei erfolgreichem (nicht-„skipped") Send: prisma.tenderMatch.updateMany({ where: { id: { in: fresh.map(m => m.id) } }, data: { notifiedAt: new Date(), notifiedChannel: 'instant' } }). Jeder Profil-Dispatch in eigenem try/catch-and-log — ein Sendefehler darf den Tick nicht crashen; bei Fehler notifiedAt NICHT setzen (Retry beim nächsten Tick/Digest). Instant läuft synchron im Tick (kein Message-Broker im Stack).
|
||||||
|
|
||||||
|
Beachte die Reihenfolge-Garantie: Instant setzt notifiedAt IMMER im Poll-Tick (vor jedem späteren Digest-Lauf) — dadurch sieht der Digest ein instant-versendetes Paar nie (D-06).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter api test -- tender-matching.service</automated>
|
||||||
|
</verify>
|
||||||
|
<done>matchDelta versendet nach Match-Erzeugung Sofort-Sammelmails nur für instantAlert=true-Profile, bündelt pro Profil/Tick, stempelt notifiedAt='instant' nur nach Erfolg und ist gegen Sendefehler robust; Spec grün.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Integrations-Spec — instant+digest ergeben genau eine Mail</name>
|
||||||
|
<files>apps/api/src/tenders/tender-notifications.integration.spec.ts</files>
|
||||||
|
<action>
|
||||||
|
Schreibe einen fokussierten Integrations-Spec, der die Kern-Invariante von NOTIFY-03 über BEIDE Kanäle beweist (mit gemocktem Prisma + gemocktem TenderMailService, kein Live-DB — analog zu den bestehenden tenders-Specs). Szenario: ein Nutzer mit digestInterval='daily', ein Profil mit instantAlert=true, ein neuer passender Tender.
|
||||||
|
|
||||||
|
Ablauf im Test: (1) matchDelta([neuerTenderId]) ausführen → erwarte genau einen sendInstant-Aufruf und dass der Match danach notifiedAt≠NULL, channel='instant' trägt. (2) Danach den Digest-Lauf (TenderDigestScheduler.runDigest) ausführen → erwarte, dass die notifiedAt=NULL-Selektion diesen bereits gestempelten Match NICHT mehr enthält und daher KEIN sendDigest-Aufruf für diesen Nutzer erfolgt. Assertion: über beide Kanäle zusammen genau EIN Mailversand (sendInstant=1, sendDigest=0).
|
||||||
|
|
||||||
|
Ergänze eine Variante: dasselbe Szenario mit instantAlert=false → sendInstant=0, und der Digest-Lauf versendet genau eine Mail (sendDigest=1). Zusammen belegt der Spec: exakt eine Mail, egal welcher Kanal.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter api test -- tender-notifications.integration</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Der Integrations-Spec beweist für instant-on genau eine Instant-Mail und null Digest-Mails, für instant-off null Instant- und genau eine Digest-Mail — kein Doppelversand über die Kanäle.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Poll-Tick (Server) → mandanten-SMTP | Sofort-Mail verlässt den Prozess synchron im Ingestion-Tick |
|
||||||
|
| TenderMatch (per-user) → Instant-Mail | Trefferdaten dürfen nur an den Profil-Eigentümer gehen |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-12-10 | Elevation of Privilege | Doppelversand Instant+Digest | high | mitigate | Instant stempelt notifiedAt+channel synchron im Tick vor jedem Digest-Lauf; Digest liest nur notifiedAt IS NULL → strukturell genau eine Mail (D-06); durch Integrations-Spec bewiesen |
|
||||||
|
| T-12-11 | Denial of Service | Sendefehler crasht den Poll-Tick | high | mitigate | Pro-Profil try/catch-and-log; bei Fehler notifiedAt NICHT setzen (Retry); der Tick (und andere Profile/Ingestion) läuft weiter |
|
||||||
|
| T-12-12 | Information Disclosure | fremde Treffer in Sofort-Mail | medium | mitigate | fresh-Query strikt savedSearchId des Profils + tenderId IN newTenderIds; Empfänger/SMTP über die im Match denormalisierte tenantId/userId des Profils |
|
||||||
|
| T-12-13 | Spam/DoS | ungewollte Mail-Flut bei instant | medium | mitigate | instantAlert Default false (D-04); Bündelung pro Profil/Tick (D-05); delta-only begrenzt Treffer auf Neu-diesen-Tick |
|
||||||
|
| T-12-SC | Tampering | npm/pip/cargo installs | high | accept | Keine Paketinstallation (RESEARCH Package Legitimacy Audit) |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter api test -- tender-matching.service` grün (Instant-Dispatch, Bündelung, Robustheit)
|
||||||
|
- `pnpm --filter api test -- tender-notifications.integration` grün (genau eine Mail über beide Kanäle)
|
||||||
|
- `pnpm --filter api test` (Wave-Merge) grün
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Ein neuer Treffer gegen ein instantAlert=true-Profil löst genau eine Sofort-Sammelmail aus
|
||||||
|
- instantAlert=false löst nie eine Sofort-Mail aus
|
||||||
|
- Ein per Instant versendetes Paar erscheint nie zusätzlich im Digest (insgesamt eine Mail)
|
||||||
|
- Ein Sendefehler crasht den Poll-Tick nicht
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/12-tender-notifications/12-03-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
---
|
||||||
|
phase: 12-tender-notifications
|
||||||
|
plan: 04
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on: [12-01, 12-02]
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/tenders/dto/notification-pref.dto.ts
|
||||||
|
- apps/api/src/tenders/tender-notification-pref.service.ts
|
||||||
|
- apps/api/src/tenders/tender-notification-pref.service.spec.ts
|
||||||
|
- apps/api/src/tenders/dto/saved-search.dto.ts
|
||||||
|
- apps/api/src/tenders/tender-saved-search.service.ts
|
||||||
|
- apps/api/src/tenders/tenders.controller.ts
|
||||||
|
- apps/api/src/tenders/tenders.module.ts
|
||||||
|
- apps/web/src/lib/tender-radar-api.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements: [NOTIFY-01, NOTIFY-02]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Ein Nutzer kann in den Ausschreibungs-Radar-Einstellungen sein Digest-Intervall auf Täglich / Wöchentlich / Aus stellen und die Wahl wird persistiert (D-01/D-03)"
|
||||||
|
- "Ein Nutzer kann pro Suchprofil einen Sofort-Alert-Toggle ein-/ausschalten (Default aus) und die Wahl wird persistiert (D-04)"
|
||||||
|
- "Alle Pref- und Profil-Routen sind strikt per userId aus dem Auth-Context gescoped — ein fremder userId kann keine Pref/kein Profil lesen oder ändern (IDOR-sicher)"
|
||||||
|
artifacts:
|
||||||
|
- "apps/api/src/tenders/tender-notification-pref.service.ts (+ Controller-Routen GET/PUT notification-pref)"
|
||||||
|
- "apps/api/src/tenders/dto/notification-pref.dto.ts (digestInterval @IsIn)"
|
||||||
|
- "apps/web/.../settings/page.tsx (Digest-Intervall-Auswahl)"
|
||||||
|
- "apps/web/.../components/SavedSearchBar.tsx (Sofort-Alert-Toggle pro Profil)"
|
||||||
|
key_links:
|
||||||
|
- "digestInterval-DTO ist @IsIn(['daily','weekly','off']); die Werte matchen exakt die Fälligkeitslogik des Digest-Schedulers (12-02)"
|
||||||
|
- "instantAlert wird über die bestehende saved-search-create/update-Route persistiert und vom Instant-Dispatch (12-03) gelesen"
|
||||||
|
- "Pref-Reads/Writes und Profil-Reads/Writes leiten userId/tenantId ausschließlich aus extractTriageContext(req) ab, nie aus Body/Query"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Die Nutzer-Oberfläche, die die beiden Benachrichtigungskanäle steuerbar macht. Backend: ein per-user TenderNotificationPref-Service (CRUD, Default 'daily') mit GET/PUT-Routen und ein instantAlert-Feld, das durch die bestehende Suchprofil-Create/Update-Route durchgereicht wird. Frontend: eine Digest-Intervall-Auswahl (Täglich/Wöchentlich/Aus) in der Tender-Radar-Settings-Page und ein Sofort-Alert-Toggle pro Suchprofil in der Phase-11-SavedSearchBar.
|
||||||
|
|
||||||
|
Purpose: NOTIFY-01 (Intervall im Webinterface konfigurierbar) + NOTIFY-02 (Sofort-Alert im Webinterface aktivierbar). Die Schema-Felder existieren bereits (12-01); dieser Plan macht sie für den Nutzer bedien- und persistierbar und schließt damit die End-to-End-Schleife: Nutzer stellt Kanal ein → Pipeline (12-01/02/03) sendet entsprechend.
|
||||||
|
|
||||||
|
Output: Pref-Service + Routen + DTOs, instantAlert-Durchreichung, erweiterte API-Client-Funktionen, Settings-Auswahl + Profil-Toggle.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/phases/12-tender-notifications/12-CONTEXT.md
|
||||||
|
@.planning/phases/12-tender-notifications/12-RESEARCH.md
|
||||||
|
@apps/api/src/tenders/tenders.controller.ts
|
||||||
|
@apps/api/src/tenders/tender-saved-search.service.ts
|
||||||
|
@apps/api/src/tenders/dto/saved-search.dto.ts
|
||||||
|
@apps/api/src/tenders/tenders.module.ts
|
||||||
|
@apps/web/src/lib/tender-radar-api.ts
|
||||||
|
@apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx
|
||||||
|
@apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Backend — Pref-Service + Routen + instantAlert-Durchreichung</name>
|
||||||
|
<files>apps/api/src/tenders/dto/notification-pref.dto.ts, apps/api/src/tenders/tender-notification-pref.service.ts, apps/api/src/tenders/tender-notification-pref.service.spec.ts, apps/api/src/tenders/dto/saved-search.dto.ts, apps/api/src/tenders/tender-saved-search.service.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.module.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1 (Default 'daily', D-01): getForUser(userId) ohne vorhandene Zeile liefert einen Default { digestInterval: 'daily' } (kein Fehler, kein Autowrite nötig).
|
||||||
|
- Test 2 (Upsert per-user, D-03): setForUser(userId, tenantId, 'weekly') upsertet auf @@unique userId und liefert digestInterval='weekly'; ein zweiter Aufruf mit 'off' aktualisiert dieselbe Zeile.
|
||||||
|
- Test 3 (Validierung, V5): das DTO akzeptiert nur 'daily'|'weekly'|'off' (@IsIn); andere Werte werden von class-validator abgewiesen.
|
||||||
|
- Test 4 (instantAlert-Durchreichung, D-04): saved-search create/update mit instantAlert=true persistiert das Feld; ohne Angabe bleibt der Default false.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Erstelle dto/notification-pref.dto.ts: UpdateNotificationPrefDto mit digestInterval: string, validiert via @IsIn(['daily','weekly','off']) (V5). KEIN userId/tenantId-Feld (IDOR — beide server-seitig aus Auth-Context, wie CreateSavedSearchDto-Kommentar).
|
||||||
|
|
||||||
|
Erstelle tender-notification-pref.service.ts (@Injectable, constructor(prisma)) nach dem TenderSavedSearchService/TenderTriageService-Scoping-Muster (userId-Scoping, KEIN forTenant()/RLS). Methoden: getForUser(userId) → prisma.tenderNotificationPref.findUnique({ where: { userId } }); bei null einen Default { digestInterval: 'daily' } zurückgeben (D-01 — konsistent mit der Default-daily-Semantik des Digest-Schedulers). setForUser(userId, tenantId, digestInterval) → prisma.tenderNotificationPref.upsert({ where: { userId }, create: { userId, tenantId, digestInterval }, update: { digestInterval } }). Schreibe den Spec zuerst (RED) gemäß behavior-Block.
|
||||||
|
|
||||||
|
Erweitere dto/saved-search.dto.ts: füge instantAlert?: boolean (@IsOptional, @IsBoolean) zu CreateSavedSearchDto UND UpdateSavedSearchDto hinzu (D-04, V5). Erweitere TenderSavedSearchService.create/update, sodass instantAlert — falls im DTO gesetzt — in die Prisma-create/update-data übernommen wird (Default false greift, wenn nicht angegeben). Ownership-Prüfung in update bleibt unverändert (userId-Vergleich, NotFound bei fremd/fehlend).
|
||||||
|
|
||||||
|
Erweitere tenders.controller.ts um zwei Routen, deklariert VOR der @Get(':id')-Route (NestJS Route-Order-Pitfall, wie bei source-config/coverage/triage/saved-searches):
|
||||||
|
- @Get('notification-pref') @UseModule('tender-radar') → { userId } = extractTriageContext(req); return prefService.getForUser(userId).
|
||||||
|
- @Put('notification-pref') @UseModule('tender-radar') → { userId, tenantId } = extractTriageContext(req); return prefService.setForUser(userId, tenantId, dto.digestInterval).
|
||||||
|
Injiziere TenderNotificationPrefService in den Controller-Konstruktor. Der instantAlert-Weg braucht KEINE neue Route — er läuft über die bestehende POST/PATCH saved-searches (DTO trägt jetzt instantAlert).
|
||||||
|
|
||||||
|
Registriere TenderNotificationPrefService als Provider in tenders.module.ts (neben den bestehenden inkl. 12-01/12-02-Providern). Aktualisiere den Modul-Doc-Kommentar.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter api test -- tender-notification-pref.service && cd apps/api && npx tsc --noEmit -p tsconfig.json && grep -q "notification-pref" src/tenders/tenders.controller.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Pref-Service (Default daily, per-user Upsert) + DTO (@IsIn) + GET/PUT-Routen vor :id + instantAlert in beiden saved-search-DTOs und im Service; Provider registriert; tsc fehlerfrei; Specs grün.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Frontend API-Client — Pref-Funktionen + instantAlert in SavedSearch</name>
|
||||||
|
<files>apps/web/src/lib/tender-radar-api.ts</files>
|
||||||
|
<action>
|
||||||
|
Erweitere tender-radar-api.ts (native fetch, credentials:'include' — kein TanStack Query, bestehendes Muster):
|
||||||
|
|
||||||
|
(a) NotificationPref-Typen + Funktionen: interface NotificationPref { digestInterval: 'daily'|'weekly'|'off' }. fetchNotificationPref(): GET /modules/tender-radar/notification-pref → NotificationPref. saveNotificationPref(digestInterval): PUT /modules/tender-radar/notification-pref mit Body { digestInterval }. Fehlerbehandlung wie die bestehenden Funktionen (res.ok-Check, aussagekräftige Error-Message).
|
||||||
|
|
||||||
|
(b) Erweitere das SavedSearch-Interface um instantAlert: boolean und CreateSavedSearchPayload/UpdateSavedSearchPayload um instantAlert?: boolean, sodass der bestehende createSavedSearch/updateSavedSearch das Feld mitsenden kann. Keine Signatur-Brüche an bestehenden Aufrufern (Feld optional).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/web && npx tsc --noEmit && grep -q "notification-pref" src/lib/tender-radar-api.ts && grep -q "instantAlert" src/lib/tender-radar-api.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>tender-radar-api.ts exportiert fetchNotificationPref/saveNotificationPref und trägt instantAlert im SavedSearch-Typ + den Payloads; tsc fehlerfrei.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Frontend UI — Digest-Intervall-Auswahl + Sofort-Alert-Toggle</name>
|
||||||
|
<files>apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx</files>
|
||||||
|
<action>
|
||||||
|
Settings-Page (settings/page.tsx): ergänze unter dem bestehenden SourceConfigForm einen eigenen Abschnitt „Benachrichtigungen" mit einer Digest-Intervall-Auswahl (select oder shadcn-RadioGroup) mit den drei Optionen Täglich / Wöchentlich / Aus. Lade den aktuellen Wert per fetchNotificationPref beim Mount, speichere Änderungen per saveNotificationPref. Hardcodiertes Deutsch (i18n = Phase 14, bestehende Konvention). Da die Page eine Server-Komponente-Shell ist, kapsle die interaktive Auswahl in eine kleine 'use client'-Komponente (analog SourceConfigForm) — z.B. inline oder als settings/components/NotificationPrefForm.tsx; halte es minimal.
|
||||||
|
|
||||||
|
SavedSearchBar (SavedSearchBar.tsx): ergänze je Profil-Chip einen Sofort-Alert-Toggle (Checkbox/Switch mit aria-label, z.B. „Sofort-Alert für {name}"), der profile.instantAlert widerspiegelt und beim Umschalten updateSavedSearch(profile.id, { instantAlert: next }) aufruft und danach load() erneut ausführt. Fehlerbehandlung im bestehenden error-State. Default-Zustand aus (D-04). Beachte den bestehenden filters-Contract nicht zu brechen — instantAlert ist ein Profil-Feld neben name/filters, kein Filter-Key.
|
||||||
|
|
||||||
|
Erweitere SavedSearchBar.test.tsx (bestehendes Vitest+Testing-Library-Muster): Test, dass der Toggle den aktuellen instantAlert-Zustand rendert und ein Klick updateSavedSearch mit { instantAlert: <negiert> } aufruft (fetch gemockt wie im bestehenden Spec).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter web test -- SavedSearchBar && cd apps/web && npx tsc --noEmit</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Settings-Page hat eine funktionierende Täglich/Wöchentlich/Aus-Auswahl (lädt + speichert Pref); SavedSearchBar hat pro Profil einen Sofort-Alert-Toggle (lädt Zustand, speichert per updateSavedSearch); SavedSearchBar-Spec grün; tsc fehlerfrei.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser (Client) → API (Pref/Profil-Routen) | Nutzer-gesteuerte digestInterval-/instantAlert-Werte kreuzen die HTTP-Grenze |
|
||||||
|
| Auth-Context → Pref/Profil-Scoping | userId/tenantId dürfen nur aus dem Cookie/Auth-Context stammen |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-12-14 | Elevation of Privilege / IDOR | GET/PUT notification-pref, saved-search-Routen | high | mitigate | userId/tenantId ausschließlich aus extractTriageContext(req); DTOs tragen kein userId/tenantId-Feld; Pref-Upsert auf @@unique userId — kein Zugriff auf fremde Zeilen |
|
||||||
|
| T-12-15 | Tampering | digestInterval-Input | medium | mitigate | @IsIn(['daily','weekly','off']) im DTO (V5) — ungültige Werte abgewiesen, bevor sie die Fälligkeitslogik erreichen |
|
||||||
|
| T-12-16 | Tampering | instantAlert-Input | low | mitigate | @IsBoolean im DTO; Ownership-Prüfung in TenderSavedSearchService.update (fremdes Profil → NotFound) |
|
||||||
|
| T-12-17 | Information Disclosure | Profil-Existenz-Leak | low | mitigate | update/remove kollabieren fehlend vs. fremd zu NotFound (bestehendes Phase-11-Muster) |
|
||||||
|
| T-12-SC | Tampering | npm/pip/cargo installs | high | accept | Keine Paketinstallation (RESEARCH Package Legitimacy Audit) |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter api test -- tender-notification-pref.service` grün
|
||||||
|
- `pnpm --filter web test -- SavedSearchBar` grün
|
||||||
|
- `npx tsc --noEmit` (api + web) fehlerfrei
|
||||||
|
- human-check (end-of-phase UAT): In der Settings-Page Intervall auf „Wöchentlich" stellen, Reload → Wert bleibt; in SavedSearchBar Sofort-Alert eines Profils einschalten, Reload → Toggle bleibt an
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Nutzer kann Digest-Intervall Täglich/Wöchentlich/Aus wählen und die Wahl wird persistiert
|
||||||
|
- Nutzer kann pro Profil Sofort-Alert ein-/ausschalten (Default aus), Wahl wird persistiert
|
||||||
|
- Alle neuen Routen sind IDOR-sicher per userId aus dem Auth-Context gescoped
|
||||||
|
- digestInterval wird server-seitig auf die drei erlaubten Werte validiert
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/12-tender-notifications/12-04-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user