16 KiB
phase, verified, status, score, behavior_unverified, overrides_applied, re_verification, human_verification
| phase | verified | status | score | behavior_unverified | overrides_applied | re_verification | human_verification | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 12-tender-notifications | 2026-07-22T08:00:00Z | passed | 4/4 must-haves verified | 0 | 0 | 2026-07-22T08:00Z — Stack rebuilt (api+web, all notification routes + digest scheduler mapped). Live UAT via Playwright + a throwaway Mailhog container (never committed, on tessera-ctl_backend-net, removed after): (1) NOTIFY-01 UI — digest interval select persisted weekly→DB→reload; (2) NOTIFY-02 UI — per-profile instant toggle persisted instantAlert=true; (3) NOTIFY-01/04 email — seeded 2 un-notified matches, ran runDigest via Nest context → Mailhog received ONE mail (To admin@tessera.local, From radar@tessera.local, subject 'Ausschreibungs-Radar: neue Treffer'), body sectioned '## Test-Radar' with both tenders, sent via the tenant SmtpConfig (not a global mailer); (4) NOTIFY-03 no-double-send — re-ran runDigest → Mailhog count stayed 1, matches stamped notifiedChannel='digest'. All test data + Mailhog cleaned up afterward. |
|
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.
-
Live SMTP send (Digest + Instant) via Mailhog
- Test: Trigger a digest run and an instant-alert dispatch against a tenant with a real
SmtpConfigpointed atlocalhost: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
nodemailertransport (unit-tested only). Also blocked on the pending Docker rebuild.
- Test: Trigger a digest run and an instant-alert dispatch against a tenant with a real
-
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 ownSmtpConfig. - 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.
- Test: Two users in two different tenants, both
-
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.
- Test: In the tender-radar settings page, set the digest interval to "Wöchentlich", reload the page; in
-
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.mdand 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)