docs(12): phase verified — 4/4 criteria, 340 tests green, UAT pending rebuild

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:37:09 +02:00
parent 314e83f5c1
commit f0948bcab1
@@ -0,0 +1,134 @@
---
phase: 12-tender-notifications
verified: 2026-07-22T09:40:00Z
status: human_needed
score: 4/4 must-haves verified
behavior_unverified: 0
overrides_applied: 0
human_verification:
- test: "Live-SMTP-Versand (Digest + Instant) gegen Mailhog (localhost:1025)"
expected: "Digest-Mail erscheint sektioniert nach Suchprofil; Instant-Mail erscheint als Sammelmail für ein Profil; beide via mandanten-eigener SMTP-Konfiguration"
why_human: "Realer Mailversand ist mit gemocktem nodemailer nicht beobachtbar — braucht ein laufendes SMTP-Relay. Erfordert zudem einen Docker-Rebuild von api/web (Container laufen seit 07:58 Uhr, Phase-12-Commits sind neuer, kein Source-Volume-Mount — die laufenden Container enthalten den neuen Code nicht)."
- test: "Zwei-Mandanten-Digest-Isolation mit echten SmtpConfig-Zeilen"
expected: "Zwei Nutzer in zwei Mandanten (beide daily) erhalten je genau ihre eigenen Treffer über ihre eigene SMTP-Config; kein Cross-Tenant-Leak"
why_human: "Braucht zwei reale Nutzer/Mandanten mit eigener SmtpConfig; im Unit-/Integrationstest nur gemockt (Prisma-Fake), nicht live beobachtbar."
- test: "Browser-UAT: Digest-Intervall in der Settings-Page auf 'Wöchentlich' stellen, Reload → Wert bleibt; SavedSearchBar Sofort-Alert-Toggle umschalten, Reload → Zustand bleibt"
expected: "Beide Werte persistieren über einen Seiten-Reload (bestätigt die GET/PUT-Rundreise end-to-end im Browser)"
why_human: "Reine UI/Browser-Verifikation; braucht laufende, aktuelle Web/API-Container (Docker-Rebuild ausstehend, siehe oben)."
- test: "Backfill-Nicht-Flut am realen Bestand (~2188 Tender): neues Suchprofil anlegen"
expected: "0 un-benachrichtigte Matches direkt nach Profilanlage, keine Mail-Flut; Treffer bleiben regulär in der Trefferliste sichtbar"
why_human: "Strukturell durch delta-only Matching bewiesen und durch einen Unit-Test mit 2188 simulierten Bestands-IDs abgedeckt (tender-matching.service.spec.ts); die reale Beobachtung am tatsächlichen ~2188-Tender-Bestand ist als Manual-Only-Verification in 12-VALIDATION.md deklariert und bleibt für die Docker-Rebuild-UAT offen."
---
# Phase 12: Tender Notifications Verification Report
**Phase Goal:** Users are proactively notified by email about new matching tenders, via a configurable digest and/or instant alert, without duplicate sends or backfill floods, using the tenant's own SMTP configuration.
**Verified:** 2026-07-22T09:40:00Z
**Status:** human_needed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths (ROADMAP Success Criteria)
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Periodischer E-Mail-Digest neuer Treffer für aktive Suchprofile, Intervall im Web-UI konfigurierbar | ✓ VERIFIED | `TenderDigestScheduler` (single global cron `0 7 * * *`, `findMany` over all due users, never `findFirst` — regression-tested by source-scan) groups un-notified matches by saved-search profile into one `sendDigest` call; `TenderNotificationPref` (default `'daily'`) drives due-check (`daily`/`weekly`-Monday-Europe/Berlin/`off`); Settings page `select` (Täglich/Wöchentlich/Aus) loads via `fetchNotificationPref`, persists via `saveNotificationPref` → `PUT /modules/tender-radar/notification-pref`. 11 scheduler unit tests + 4 pref-service tests, all green. |
| 2 | Optionaler Sofort-Alert pro Suchprofil, eine Sammel-Mail kurz nach neuem Treffer | ✓ VERIFIED | `TenderSavedSearch.instantAlert` (default `false`) settable via `SavedSearchBar` checkbox (`aria-label="Sofort-Alert für {name}"`) → `updateSavedSearch(id, {instantAlert})`. `TenderMatchingService.matchDelta` dispatches, AFTER match upserts of the tick, one bundled `sendInstant` call per `instantAlert=true` profile covering all its fresh (`notifiedAt=NULL`) matches of that tick — never one mail per match. 5 dedicated unit tests (selectivity, bundling, stamping, retry-safety, no-op-when-nothing-new), all green. |
| 3 | Kein Doppelversand + keine Rückstau-Flut — explizites matched-vs-notified-State, `notifiedAt` single-gate, `@@unique(tenderId,savedSearchId)`, Paar nie zweimal (Digest+Instant), neues Profil flutet nicht | ✓ VERIFIED | `TenderMatch` model: `@@unique([tenderId, savedSearchId])` upsert target with `update: {}` (idempotent, preserves a set `notifiedAt`); single nullable `notifiedAt` column is the sole eligibility gate read by both digest (`WHERE notifiedAt: null`) and instant dispatch. Delta-only matching: `matchDelta` only ever evaluates `id IN newTenderIds`, collected in `pollDueSources` via an indexed `dedupKey` pre-check — no historical rescan, no backfill/suppression table. Cross-channel guarantee proven end-to-end by `tender-notifications.integration.spec.ts` (2 scenarios: `instantAlert=true` ⇒ sendInstant=1/sendDigest=0; `instantAlert=false` ⇒ sendInstant=0/sendDigest=1). Backfill-non-flood additionally unit-proven with a 2188-row simulated pre-existing catalog (`tender-matching.service.spec.ts`, test: "produces ZERO matches when ~2188 pre-existing tenders are simulated as NOT in newTenderIds"). |
| 4 | E-Mail-Versand über mandanten-eigene SMTP-Konfiguration, nicht globaler Mailer | ✓ VERIFIED | `TenderMailService.resolveTransport(tenantId)` calls `settingsService.getDecryptedSmtpConfig(tenantId)` on every send, builds a fresh `nodemailer.createTransport(...)` per call (never cached/global), `transport.close()` in `finally` even on send failure; missing config resolves to `false` (skip, no throw) so `notifiedAt` is not stamped and the pair retries. No global/shared mailer instance exists in this code path. 9 unit tests green (SMTP resolution, fresh-transport+close incl. on failure, no-throw skip, sectioned body, HTML-escaping). |
**Score:** 4/4 truths verified (0 present, behavior-unverified)
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/prisma/schema.prisma` | `TenderMatch`, `TenderNotificationPref`, `TenderSavedSearch.instantAlert` | ✓ VERIFIED | All three present exactly as specified: single `notifiedAt DateTime?`, `@@unique([tenderId, savedSearchId])`, `@@index([userId])`, `@@index([notifiedAt])`; `TenderNotificationPref` per-user `@@unique(userId)`, default `'daily'`; `instantAlert Boolean @default(false)` on `TenderSavedSearch`. |
| `apps/api/prisma/migrations/20260722100000_add_tender_notifications/migration.sql` | Applied to local dev DB | ✓ VERIFIED | `to_regclass('public."TenderMatch"')`/`to_regclass('public."TenderNotificationPref"')` both non-null in the running `db` container; `\d "TenderSavedSearch"` shows `instantAlert boolean not null default false`; `_prisma_migrations` row present and `finished_at` set (no `rolled_back_at`). |
| `apps/api/src/tenders/tender-matching.service.ts` | `matchDelta` (delta-only matching + instant dispatch) | ✓ VERIFIED | Reused `buildTenderWhere`, delta boundary `id IN newTenderIds`, idempotent upsert, instant dispatch filtering `instantAlert===true`, per-profile try/catch. 11 unit tests green. |
| `apps/api/src/tenders/tender-mail.service.ts` | `sendDigest`/`sendInstant`, DkvMailService-clone | ✓ VERIFIED | As described above. 9 unit tests green. |
| `apps/api/src/tenders/tender-digest.scheduler.ts` | Single global cron | ✓ VERIFIED | `findMany`-based fan-out; source explicitly does not contain the literal `findFirst` (regression-tested). 11 unit tests green. |
| `apps/api/src/tenders/tender-notification-pref.service.ts` | Per-user CRUD, default daily | ✓ VERIFIED | `getForUser`/`setForUser`, upsert on `@@unique(userId)`. 4 unit tests green. |
| `apps/api/src/tenders/dto/notification-pref.dto.ts` | `@IsIn(['daily','weekly','off'])` | ✓ VERIFIED | Confirmed in DTO; no `userId`/`tenantId` field (IDOR). |
| `apps/api/src/tenders/dto/saved-search.dto.ts` | `instantAlert?: boolean` on Create+Update | ✓ VERIFIED | `@IsOptional() @IsBoolean() instantAlert?: boolean` on both DTOs. |
| `apps/web/src/lib/tender-radar-api.ts` | `fetchNotificationPref`/`saveNotificationPref`, `instantAlert` in SavedSearch types | ✓ VERIFIED | Present per grep + `tsc --noEmit` clean. |
| `apps/web/.../settings/page.tsx` | Digest-interval selector | ✓ VERIFIED | Loads on mount, saves on change, "Benachrichtigungen" section present. |
| `apps/web/.../components/SavedSearchBar.tsx` | Sofort-Alert-Toggle per profile | ✓ VERIFIED | Checkbox with `aria-label`, calls `updateSavedSearch(id,{instantAlert})` + reload; 2 dedicated component tests green. |
| `apps/api/src/tenders/tender-notifications.integration.spec.ts` | Instant+Digest = exactly one mail | ✓ VERIFIED | 2 scenarios, both proving the single-mail cross-channel invariant. |
### Key Link Verification
| From | To | Via | Status | Details |
|------|-----|-----|--------|---------|
| `tender-ingestion.service.ts` (`pollDueSources`) | `tender-matching.service.ts` (`matchDelta`) | End-of-tick call with genuinely-new tender IDs (indexed `dedupKey` pre-check) | ✓ WIRED | `if (newTenderIds.length) await this.matching.matchDelta(newTenderIds);` inside the try block, wrapped by the tick's own catch-and-log. |
| `tender-matching.service.ts` | `tender-mail.service.ts` (`sendInstant`) | Constructor DI, called after match upserts for `instantAlert=true` profiles | ✓ WIRED | Confirmed in source + 5 unit tests. |
| `tender-digest.scheduler.ts` | `tender-mail.service.ts` (`sendDigest`) | Per due-user call with grouped sections | ✓ WIRED | Confirmed in source + 11 unit tests. |
| `tender-mail.service.ts` | `settings.service.ts` (`getDecryptedSmtpConfig`) | Constructor DI (`SettingsModule` imported in `TendersModule`) | ✓ WIRED | `grep SettingsModule tenders.module.ts` present; `tsc --noEmit` clean (DI graph resolvable). |
| `tenders.controller.ts` (`notification-pref` routes) | `tender-notification-pref.service.ts` | `extractTriageContext(req)` → `userId`/`tenantId`, never body/query | ✓ WIRED | Routes declared before `@Get(':id')` (route-order pitfall respected); IDOR test present in `tenders.controller.spec.ts`. |
| `SavedSearchBar.tsx` / `settings/page.tsx` | `tender-radar-api.ts` → API routes | `fetchNotificationPref`/`saveNotificationPref`, `updateSavedSearch({instantAlert})` | ✓ WIRED | Confirmed via component tests + `tsc --noEmit`. |
### Requirements Coverage
| Requirement | Source Plan(s) | Description | Status | Evidence |
|---|---|---|---|---|
| NOTIFY-01 | 12-02, 12-04 | Periodic digest, interval configurable in web UI | ✓ SATISFIED | TenderDigestScheduler + TenderNotificationPref + settings-page selector |
| NOTIFY-02 | 12-03, 12-04 | Optional instant alert per profile, activatable in web UI | ✓ SATISFIED | Instant dispatch in matchDelta + SavedSearchBar toggle + instantAlert DTO/schema field |
| NOTIFY-03 | 12-01, 12-02, 12-03 | matched-vs-notified distinction; no double-send; no backfill flood | ✓ SATISFIED | TenderMatch single-notifiedAt gate, delta-only matching, cross-channel integration spec |
| NOTIFY-04 | 12-02 | Tenant-specific SMTP, not global mailer | ✓ SATISFIED | TenderMailService.resolveTransport per tenantId per send |
No orphaned requirements found — REQUIREMENTS.md maps exactly NOTIFY-01..04 to Phase 12, all four appear in at least one plan's `requirements` field.
### Anti-Patterns Found
None. Scanned all 12 phase-12 modified/created files (api services, DTOs, controller, module, web api client, settings page, SavedSearchBar) for `TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER|not yet implemented|coming soon` — zero matches.
### Behavioral Spot-Checks / Aggregate Test Runs
| Check | Command | Result | Status |
|---|---|---|---|
| Full apps/api suite | `cd apps/api && npx vitest run` | 209/209 tests passed, 19 files | ✓ PASS |
| Full apps/web suite | `cd apps/web && npx vitest run` | 131/131 tests passed, 23 files | ✓ PASS |
| apps/api typecheck | `cd apps/api && npx tsc --noEmit -p tsconfig.json` | clean, exit 0 | ✓ PASS |
| apps/web typecheck | `cd apps/web && npx tsc --noEmit` | clean, exit 0 | ✓ PASS |
| Migration applied | `psql \| to_regclass('TenderMatch'/'TenderNotificationPref')` against running `db` container | both non-null; `_prisma_migrations` row present, `finished_at` set | ✓ PASS |
| 2188-row backfill simulation | `tender-matching.service.spec.ts` test name grep | test exists and is part of the 209 green tests | ✓ PASS |
| Two-channel single-email integration spec | `tender-notifications.integration.spec.ts` (2 scenarios) | included in the 209 green tests | ✓ PASS |
| Route-order regression (no `findFirst` in digest scheduler source) | source-scan test inside `tender-digest.scheduler.spec.ts` | included in the 11 green scheduler tests | ✓ PASS |
### Probe Execution
Not applicable — this phase has no `scripts/*/tests/probe-*.sh` conventions and none were declared in PLAN/SUMMARY files.
### Human Verification Required
The following items require a running, rebuilt application (the `api`/`web` Docker containers were last built at 07:58 CEST; all Phase 12 commits are newer, and `docker-compose.yml` builds `api`/`web` from source with no dev volume mount — so the currently running containers do NOT contain the Phase 12 code). Per project convention, the user performs the Docker rebuild, not this agent.
1. **Live SMTP send (Digest + Instant) via Mailhog**
- Test: Trigger a digest run and an instant-alert dispatch against a tenant with a real `SmtpConfig` pointed at `localhost:1025` (Mailhog).
- Expected: Digest mail is sectioned by saved-search profile name; instant mail is a single collective mail for one profile; both delivered via the tenant's own SMTP config.
- Why human: Real mail delivery cannot be observed with a mocked `nodemailer` transport (unit-tested only). Also blocked on the pending Docker rebuild.
2. **Two-tenant digest isolation with real SmtpConfig rows**
- Test: Two users in two different tenants, both `digestInterval='daily'`, each with un-notified matches and their own `SmtpConfig`.
- Expected: Each user receives exactly their own matches via their own tenant's SMTP; no cross-tenant leak.
- Why human: Requires two real tenants/users/SmtpConfig rows; only mocked in unit/integration tests.
3. **Browser UAT — settings persistence across reload**
- Test: In the tender-radar settings page, set the digest interval to "Wöchentlich", reload the page; in `SavedSearchBar`, toggle a profile's Sofort-Alert, reload the page.
- Expected: Both values persist across the reload (confirms the GET/PUT round-trip end-to-end in a real browser).
- Why human: UI/browser verification; blocked on the pending Docker rebuild.
4. **Backfill non-flood at the real ~2188-tender catalog**
- Test: Create a new saved-search profile against the actual production-like tender catalog.
- Expected: Zero un-notified matches immediately after creation; no mail flood; existing matching tenders remain visible in the regular results list.
- Why human: Structurally guaranteed by delta-only matching and unit-proven with a 2188-row simulated catalog, but the real-catalog observation is explicitly declared as a Manual-Only Verification in `12-VALIDATION.md` and requires the pending Docker rebuild/live run to observe directly.
### Gaps Summary
No code-level gaps found. All 4 ROADMAP success criteria, all 4 REQUIREMENTS (NOTIFY-01..04), and all must-have truths/artifacts/key-links declared across the 4 plans are verified present, substantive, and wired — backed by 209/209 (api) + 131/131 (web) passing tests and clean `tsc --noEmit` in both apps. The only open items are live-environment/browser verifications that explicitly require a Docker rebuild the user performs (per project convention and per this phase's own `12-VALIDATION.md` "Manual-Only Verifications" section) — these are routed to human verification, not gaps.
---
*Verified: 2026-07-22T09:40:00Z*
*Verifier: Claude (gsd-verifier)*