9.5 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 12-tender-notifications | 03 | execute | 3 |
|
|
true |
|
|
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.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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 Task 1: Instant-Dispatch in matchDelta (RED→GREEN) apps/api/src/tenders/tender-matching.service.ts, apps/api/src/tenders/tender-matching.service.spec.ts - 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. 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). pnpm --filter api test -- tender-matching.service 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.
Task 2: Integrations-Spec — instant+digest ergeben genau eine Mail apps/api/src/tenders/tender-notifications.integration.spec.ts 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. pnpm --filter api test -- tender-notifications.integration 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.
<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> |
<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>