Files

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
12-01
12-02
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
true
NOTIFY-02
NOTIFY-03
truths artifacts key_links
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)
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)
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
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.

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

<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>
Create `.planning/phases/12-tender-notifications/12-03-SUMMARY.md` when done