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

129 lines
9.5 KiB
Markdown

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