docs(12-03): complete instant-alert dispatch plan

This commit is contained in:
2026-07-22 09:23:21 +02:00
parent 5395ce6b6d
commit 1a0cd375c2
4 changed files with 167 additions and 12 deletions
+2 -2
View File
@@ -45,7 +45,7 @@
### NOTIFY — Benachrichtigung
- [x] **NOTIFY-01**: Nutzer erhält einen periodischen E-Mail-Digest passender Treffer; Intervall im Webinterface konfigurierbar.
- [ ] **NOTIFY-02**: Nutzer erhält optional eine Sofort-E-Mail bei einem neuen Treffer eines aktiven Suchprofils; im Webinterface aktivierbar.
- [x] **NOTIFY-02**: Nutzer erhält optional eine Sofort-E-Mail bei einem neuen Treffer eines aktiven Suchprofils; im Webinterface aktivierbar.
- [x] **NOTIFY-03**: Das System unterscheidet „getroffen" von „benachrichtigt" (kein Doppelversand Digest+Sofort; kein Rückstau-Massenversand beim Anlegen eines Suchprofils).
- [x] **NOTIFY-04**: Der E-Mail-Versand nutzt die mandantenspezifische SMTP-Konfiguration (bestehendes DKV-Mail-Muster).
@@ -93,7 +93,7 @@
| UI-04 | Phase 11 | Complete |
| UI-05 | Phase 11 | Complete |
| NOTIFY-01 | Phase 12 | Complete |
| NOTIFY-02 | Phase 12 | Pending |
| NOTIFY-02 | Phase 12 | Complete |
| NOTIFY-03 | Phase 12 | Complete |
| NOTIFY-04 | Phase 12 | Complete |
| INGEST-02 | Phase 13 | Pending |
+3 -3
View File
@@ -410,7 +410,7 @@ 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)
4. Notification emails are sent through the tenant's own SMTP configuration (reusing the DKV mail pattern), not a shared/global system mailer
**Plans**: 2/4 plans executed
**Plans**: 3/4 plans executed
**UI hint**: yes
**Wave 1**
@@ -423,7 +423,7 @@ Plans:
**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)
- [x] 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
@@ -476,6 +476,6 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
| 9. Cert Manager Module | 6/6 | Complete | 2026-07-02 |
| 10. Ausschreibungs-Radar Foundation & DÖE Ingestion | 6/6 | Complete | 2026-07-21 |
| 11. Filter Engine, Results UI & Saved Searches | 6/6 | In Progress| |
| 12. Tender Notifications | 2/4 | In Progress| |
| 12. Tender Notifications | 3/4 | In Progress| |
| 13. Scraping Adapters & Cross-Source Deduplication | 0/TBD | Not started | - |
| 14. RSS, Email-Alert Ingestion & Module Rollout | 0/TBD | Not started | - |
+9 -7
View File
@@ -5,15 +5,15 @@ milestone_name: Ausschreibungs-Radar
current_phase: 12
current_phase_name: tender-notifications
status: executing
stopped_at: Completed 12-02-PLAN.md
last_updated: "2026-07-22T07:16:13.211Z"
stopped_at: Completed 12-03-PLAN.md
last_updated: "2026-07-22T07:23:12.233Z"
last_activity: 2026-07-22
last_activity_desc: Phase 12 execution started
progress:
total_phases: 12
completed_phases: 10
total_plans: 56
completed_plans: 53
completed_plans: 54
---
# Project State
@@ -28,11 +28,11 @@ See: .planning/PROJECT.md (updated 2026-07-17)
## Current Position
Phase: 12 (tender-notifications) — EXECUTING
Plan: 3 of 4
Plan: 4 of 4
Status: Ready to execute
Last activity: 2026-07-22 — Phase 12 execution started
Progress: [██████████] 95%
Progress: [██████████] 96%
## Performance Metrics
@@ -88,6 +88,7 @@ Progress: [██████████] 95%
| Phase 11 P06 | 35min | 3 tasks | 12 files |
| Phase 12 P01 | 35min | 3 tasks | 7 files |
| Phase 12 P02 | 8min | 3 tasks | 5 files |
| Phase 12 P03 | 15min | 2 tasks | 3 files |
## Accumulated Context
@@ -187,6 +188,7 @@ Recent decisions affecting current work:
- [Phase ?]: Delta-only matching (no backfill/suppression table) structurally prevents backfill-flood for new saved-search profiles (12-01, D-07)
- [Phase ?]: TenderMailService swallows missing-SmtpConfig and send-failure into a single boolean (never throws) so the digest cron gets one clean success signal for stamping notifiedAt
- [Phase ?]: TenderDigestScheduler.runDigest(now) takes an injectable clock parameter for testable Monday-only weekly-digest gating
- [Phase ?]: Instant-Dispatch filtert die bereits geladenen savedSearches (kein zweiter Query); notifiedAt/channel='instant' nur nach Erfolg gestempelt — identisches Gate wie Digest (D-06)
### Pending Todos
@@ -224,7 +226,7 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-07-22T07:16:13.196Z
Stopped at: Completed 12-02-PLAN.md
Last session: 2026-07-22T07:23:12.215Z
Stopped at: Completed 12-03-PLAN.md
Resume file: None
Last activity: 2026-07-14 - Built LDAP per-user exclude/denylist filter (9d1323f), migration applied on live DB, verified via Playwright: sync deactivated 4 excluded service accounts (administrator/krbtgt/guest/dns-ldap), 2 real LDAP users stay active, 0 wrongly created
@@ -0,0 +1,153 @@
---
phase: 12-tender-notifications
plan: 03
subsystem: api
tags: [nestjs, prisma, tender-radar, notifications, instant-alert, tdd]
requires:
- phase: 12-01
provides: "TenderMatch model (single nullable notifiedAt eligibility gate) + TenderMatchingService.matchDelta"
- phase: 12-02
provides: "TenderMailService.sendInstant (already implemented, no-throw boolean signal) + TenderDigestScheduler reading the same notifiedAt gate"
provides:
- "TenderMatchingService.matchDelta — instant-alert dispatch at end of the poll tick for instantAlert=true profiles"
- "Per-profile bundled instant mail (D-05): all of a profile's freshly-matched tenders in one tick become ONE sendInstant call"
- "tender-notifications.integration.spec.ts — proves instant+digest together always send exactly one mail (NOTIFY-03 core invariant, D-06)"
affects: [12-04-tender-notification-settings-ui]
tech-stack:
added: []
patterns:
- "Instant dispatch runs AFTER all match upserts of the tick, filtering the same savedSearches list already loaded for matching (no second tenderSavedSearch.findMany)"
- "notifiedAt/notifiedChannel='instant' stamped ONLY on a successful (non-skipped, non-thrown) sendInstant — the exact same gate the digest reads, so the two channels can never double-notify a pair"
- "Per-profile try/catch around instant dispatch — a send failure/thrown error for one profile never aborts the tick or the remaining profiles' dispatch"
key-files:
created:
- apps/api/src/tenders/tender-notifications.integration.spec.ts
modified:
- apps/api/src/tenders/tender-matching.service.ts
- apps/api/src/tenders/tender-matching.service.spec.ts
key-decisions:
- "Instant dispatch filters the savedSearches array already loaded at the top of matchDelta (instantAlert===true) rather than a second DB query — one findMany covers both matching and instant-eligibility"
- "TenderMailService injected as a second constructor parameter (Nest DI resolves it automatically via the existing tenders.module.ts provider registration — no module change needed, exactly as 12-02's SUMMARY anticipated)"
- "A profile with instantAlert=true but zero fresh (notifiedAt=NULL) matches this tick is a silent no-op (no sendInstant call) — checked before the user lookup to avoid an unnecessary query"
patterns-established:
- "Shared hand-rolled Prisma fake across matchDelta and runDigest in the integration spec: one in-memory Map-backed tenderMatch store lets both services' real state transitions (upsert -> instant stamp -> digest candidate exclusion) be asserted end-to-end without a live DB"
requirements-completed: [NOTIFY-02, NOTIFY-03]
coverage:
- id: D1
description: "matchDelta dispatches instant alerts only for instantAlert=true profiles (D-04); instantAlert=false profiles never trigger sendInstant"
requirement: "NOTIFY-02"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-matching.service.spec.ts#Instant-Dispatch nur instantAlert=true (D-04)"
status: pass
human_judgment: false
- id: D2
description: "Multiple freshly-matched tenders of the same profile in one tick bundle into exactly ONE sendInstant call (D-05), never one mail per match"
requirement: "NOTIFY-02"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-matching.service.spec.ts#Bündelung pro Profil/Tick (D-05)"
status: pass
human_judgment: false
- id: D3
description: "notifiedAt/notifiedChannel='instant' stamped only after a successful send; a send failure/thrown error leaves notifiedAt NULL for that profile and does not abort the tick for other profiles"
requirement: "NOTIFY-03"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-matching.service.spec.ts#Stempelung nach Erfolg (D-06) / Retry-Sicherheit (Robustheit)"
status: pass
human_judgment: false
- id: D4
description: "An instantAlert=true profile with no fresh notifiedAt=NULL matches this tick never calls sendInstant (no spurious mail)"
requirement: "NOTIFY-02"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-matching.service.spec.ts#nichts Neues löst keinen Alert aus"
status: pass
human_judgment: false
- id: D5
description: "Cross-channel single-email guarantee: instantAlert=true yields sendInstant=1/sendDigest=0; instantAlert=false yields sendInstant=0/sendDigest=1 — never both, never zero"
requirement: "NOTIFY-03"
verification:
- kind: integration
ref: "apps/api/src/tenders/tender-notifications.integration.spec.ts (both scenarios)"
status: pass
human_judgment: false
- id: D6
description: "Actual SMTP delivery of a real instant-alert email against a live mailbox (Mailhog), and true multi-tenant instant-alert isolation with two real users/tenants"
verification: []
human_judgment: true
rationale: "Real mail delivery cannot be observed with a mocked nodemailer transport (TenderMailService is unit-tested with mocks in 12-02); needs a live SMTP relay and real per-tenant SmtpConfig rows — deferred to the phase-end UAT pass per 12-VALIDATION.md Manual-Only Verifications, consistent with 12-01/12-02's precedent."
duration: ~15min
completed: 2026-07-22
status: complete
---
# Phase 12 Plan 03: Instant-Alert-Kanal + Ein-Mail-Garantie über beide Kanäle Summary
**matchDelta um synchronen Instant-Dispatch am Poll-Tick erweitert (per-Profil-Bündelung, notifiedAt='instant' nur nach Erfolg) plus ein Integrations-Spec, der beweist, dass instant+digest zusammen strukturell genau eine Mail pro Tender×Profil-Paar ergeben.**
## Performance
- **Duration:** ~15 min
- **Completed:** 2026-07-22
- **Tasks:** 2 completed (Task 1 followed RED→GREEN TDD)
- **Files modified:** 3 (1 created, 2 modified)
## Accomplishments
- Erweiterte `TenderMatchingService.matchDelta`: NACH allen Match-Upserts eines Ticks filtert der Service die bereits geladenen Suchprofile auf `instantAlert===true`. Für jedes solche Profil werden dessen frische (`notifiedAt=NULL`, `tenderId IN newTenderIds`) Treffer geladen; bei mindestens einem Treffer wird `TenderMailService.sendInstant` GENAU EINMAL pro Profil pro Tick aufgerufen (D-05 — Sammelmail, nie eine Mail pro Treffer).
- `notifiedAt`/`notifiedChannel='instant'` werden NUR bei erfolgreichem (nicht-„skipped", nicht-geworfenem) Send gestempelt — dasselbe Eligibility-Gate, das der Digest liest (D-06). Bei Fehlschlag/Exception bleibt `notifiedAt` NULL für dieses Profil (Retry beim nächsten Tick/Digest); jeder Profil-Dispatch läuft in eigenem try/catch, sodass ein Sendefehler weder den Tick noch die übrigen Profile abbricht.
- `TenderMailService` als zweiter Konstruktor-Parameter injiziert — keine `tenders.module.ts`-Änderung nötig, da beide Provider dort bereits registriert sind (wie von 12-02s Summary vorausgesehen).
- Neuer Integrations-Spec `tender-notifications.integration.spec.ts`: führt `matchDelta` und `runDigest` gegen einen gemeinsamen, gemockten Prisma-Store aus (kein Live-DB, gemocktes `TenderMailService`, kein Live-SMTP) und beweist die NOTIFY-03-Kern-Invariante End-to-End über beide Kanäle — `instantAlert=true` ⇒ `sendInstant=1, sendDigest=0`; `instantAlert=false` ⇒ `sendInstant=0, sendDigest=1`. In beiden Szenarien: `sendInstant + sendDigest === 1`.
- Volle `apps/api`-Suite grün: 199/199 Tests über 18 Dateien; `npx tsc --noEmit` sauber.
## Task Commits
Each task was committed atomically:
1. **Task 1: Instant-Dispatch in matchDelta (RED→GREEN)** - `86c184f` (test, RED) → `ec032ad` (feat, GREEN)
2. **Task 2: Integrations-Spec — instant+digest ergeben genau eine Mail** - `5395ce6` (test)
**Plan metadata:** commit pending (this SUMMARY + STATE/ROADMAP update)
## Files Created/Modified
- `apps/api/src/tenders/tender-matching.service.ts` - Instant-Dispatch am Ende von `matchDelta`: Filter auf `instantAlert===true`, fresh-Match-Lookup, `sendInstant`-Aufruf pro Profil/Tick, Stempelung nur nach Erfolg, Per-Profil try/catch
- `apps/api/src/tenders/tender-matching.service.spec.ts` - 5 neue Tests (D-04 Selektivität, D-05 Bündelung, D-06 Stempelung, Retry-Sicherheit, „nichts Neues"); bestehende Fake-Prisma um `tenderMatch.findMany`/`updateMany` + `user.findUnique` erweitert; alle Instanziierungen um `mail`-Mock ergänzt
- `apps/api/src/tenders/tender-notifications.integration.spec.ts` - 2 Szenarien (instant-on, instant-off) mit gemeinsamem gemocktem Prisma-Store; beweist die Ein-Mail-Garantie über beide Kanäle
## Decisions Made
- Instant-Dispatch nutzt die bereits am Anfang von `matchDelta` geladene `savedSearches`-Liste (keine zweite `tenderSavedSearch.findMany`) — ein Query deckt sowohl Matching als auch Instant-Eligibility ab.
- Ein `instantAlert=true`-Profil ohne frische Treffer ist ein stiller No-Op (kein `sendInstant`-Aufruf) — geprüft vor dem User-Lookup, um eine unnötige Query zu vermeiden.
- Integrations-Spec teilt EINEN Prisma-Fake-Store zwischen `TenderMatchingService` und `TenderDigestScheduler`, damit der Zustandsübergang (Upsert → Instant-Stempel → Digest-Ausschluss) real durchläuft, statt beide Services isoliert zu mocken.
## Deviations from Plan
None - plan executed exactly as written. `TenderMailService` war bereits (aus 12-02) mit `sendInstant` fertig implementiert; `tenders.module.ts` musste wie geplant nicht geändert werden.
## Issues Encountered
Ein initialer TypeScript-Fehler (`unknown[]` nicht zuweisbar zu `TenderMailItem[]`) durch eine zu enge explizite Parameter-Annotation in den `.map()`-Aufrufen der Instant-Dispatch-Logik — behoben durch Entfernen der Annotationen und Verlassen auf Typinferenz aus Prisma's `include: { tender: true }`-Rückgabetyp. Kein Verhaltensunterschied, reine Typkorrektur vor dem ersten `tsc`-Lauf (kein separater Rule-1-Eintrag, da vor jeglicher Verifikation/Commit behoben).
## User Setup Required
None - keine externe Service-Konfiguration erforderlich. `TenderMailService.sendInstant` nutzt die bestehende mandanten-SMTP-Konfiguration (Phase 7/12-02).
## Next Phase Readiness
- Die NOTIFY-03-Kern-Invariante (kein Doppelversand über Instant+Digest) ist jetzt vollständig implementiert und End-to-End bewiesen — Plan 12-04 (Settings-UI: Digest-Intervall + Sofort-Toggle pro Profil) kann direkt auf die bestehenden `instantAlert`/`digestInterval`-Felder aufsetzen, ohne weitere Backend-Änderungen an Matching/Dispatch.
- Reale SMTP-Zustellung eines Sofort-Alerts (Mailhog) und Multi-Tenant-Isolation mit zwei echten Nutzern bleiben offen für die Phase-Ende-UAT (12-VALIDATION.md), konsistent mit 12-01/12-02.
- Keine Blocker. Volle `apps/api`-Suite grün (199/199, 18 Dateien); `npx tsc --noEmit` sauber.
---
*Phase: 12-tender-notifications*
*Completed: 2026-07-22*
## Self-Check: PASSED
All created/modified files verified present on disk; all 3 task commit hashes (86c184f, ec032ad, 5395ce6) verified present in git log.