Files
tessera-ctl/.planning/phases/12-tender-notifications/12-VERIFICATION.md
T
2026-07-22 09:37:09 +02:00

16 KiB

phase, verified, status, score, behavior_unverified, overrides_applied, human_verification
phase verified status score behavior_unverified overrides_applied human_verification
12-tender-notifications 2026-07-22T09:40:00Z human_needed 4/4 must-haves verified 0 0
test expected why_human
Live-SMTP-Versand (Digest + Instant) gegen Mailhog (localhost:1025) Digest-Mail erscheint sektioniert nach Suchprofil; Instant-Mail erscheint als Sammelmail für ein Profil; beide via mandanten-eigener SMTP-Konfiguration 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 expected why_human
Zwei-Mandanten-Digest-Isolation mit echten SmtpConfig-Zeilen Zwei Nutzer in zwei Mandanten (beide daily) erhalten je genau ihre eigenen Treffer über ihre eigene SMTP-Config; kein Cross-Tenant-Leak Braucht zwei reale Nutzer/Mandanten mit eigener SmtpConfig; im Unit-/Integrationstest nur gemockt (Prisma-Fake), nicht live beobachtbar.
test expected why_human
Browser-UAT: Digest-Intervall in der Settings-Page auf 'Wöchentlich' stellen, Reload → Wert bleibt; SavedSearchBar Sofort-Alert-Toggle umschalten, Reload → Zustand bleibt Beide Werte persistieren über einen Seiten-Reload (bestätigt die GET/PUT-Rundreise end-to-end im Browser) Reine UI/Browser-Verifikation; braucht laufende, aktuelle Web/API-Container (Docker-Rebuild ausstehend, siehe oben).
test expected why_human
Backfill-Nicht-Flut am realen Bestand (~2188 Tender): neues Suchprofil anlegen 0 un-benachrichtigte Matches direkt nach Profilanlage, keine Mail-Flut; Treffer bleiben regulär in der Trefferliste sichtbar 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.
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)