Files
tessera-ctl/.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-VERIFICATION.md
T
schalli c8bf229524
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 48s
Tessera CI/CD / Build & Publish Images (push) Successful in 7s
docs(14): phase verification — 4/5 verified, criterion 2 (EWS live) human_needed
Alle Code-Pläne (14-01..14-05) fertig + getestet (389/389 API, 151/151 web,
tsc clean). D-13 Mandanten-Isolation (write ownerTenantId create-only +
read OR-filter + getTender 404-guard) im Code bestätigt, Migration nullable
ohne Backfill. Einziger offener Punkt: 14-03 Task 4 = Live-EWS-Test an echtem
Postfach (human_verification, kein Code-Gap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:28:01 +02:00

18 KiB

phase, verified, status, score, behavior_unverified, overrides_applied, human_verification
phase verified status score behavior_unverified overrides_applied human_verification
14-rss-email-alert-ingestion-module-rollout 2026-07-23T12:26:12Z human_needed 4/5 must-haves verified (criterion 2 partial — code+IMAP/mock verified, live EWS pending human) 0 0
test expected why_human
14-03-PLAN.md Task 4 (checkpoint:human-verify, gate=blocking) — configure a real portal-alert mailbox in Tessera → Ausschreibungs-Radar → Einstellungen → E-Mail-Alerts, try the Exchange/EWS path specifically, activate the email-alert poll config, and let a real alert email be ingested. (a) a real alert email produces a tenant-private tender (visible only to the configuring tenant); (b) a different tenant does NOT see it; (c) the EWS body-fetch (getItemBodySoap/fetchMessagesViaEws) returns non-empty, non-garbled HTML/text content against a real Exchange mailbox — not just the mocked httpntlm.post response exercised in exchange-inbox.provider.spec.ts. Requires a live, real portal-alert mailbox and a live Exchange/EWS server. EWS/NTLM is a documented-fragile hand-rolled SOAP path (project memory: project_calendar_ews_fix) with zero prior test coverage before this phase; the new fetchMessages()/getItemBodySoap() code is only proven against mocked httpntlm.post in exchange-inbox.provider.spec.ts (6 tests), never against a real server. No real mailbox is available to automated verification. IMAP path is separately covered by 7 mocked unit tests (imap.provider.spec.ts) but also never exercised live.

Phase 14: RSS, Email-Alert Ingestion & Module Rollout Verification Report

Phase Goal: The platform rounds out Unterschwelle long-tail coverage via RSS feeds and per-tenant email-alert mailboxes (reusing a shared inbox module extracted from DKV), admins can manage source and mailbox configuration per tenant, excluded portals are shown transparently, and the whole module is bilingual. Verified: 2026-07-23T12:26:12Z Status: human_needed Re-verification: No — initial verification

Goal Achievement

Observable Truths (5 ROADMAP Success Criteria)

# Truth Status Evidence
1 Tenders from RSS feeds subreport-elvis and service.bund.de appear automatically in the results list (INGEST-04) ✓ VERIFIED apps/api/src/tenders/adapters/rss.adapter.ts — RssAdapter.parseFeed() maps both live-captured feed shapes (service.bund.de w/ pubDate + numeric-entity titles; subreport-elvis w/o pubDate, CDATA titles) into RawTenderRecord[], routed through TenderNormalizerService.normalize()'s case 'rss': return this.normalizeBag(raw) (tender-normalizer.service.ts:57-59). Registered in tenders.module.ts (RssAdapter provided, registry.register()'d, sourceType:'rss' poll config seeded isActive:true, pollGranularity:'tick'). pollGranularity:'tick' branch confirmed live in tender-ingestion.service.ts:135-155 — RSS is fetched unconditionally every active tick, bypassing the day-cursor gate (D-15/Pitfall 1 fix). 18 fixture-driven adapter tests + 2 normalizer tests + 3 pollGranularity-gate tests all pass (see live run below).
2 Tenders parsed from a configured mailbox's portal-alert emails appear automatically in the results list, built on a shared inbox/ module extracted from DKV (INGEST-05) ⚠️ PARTIAL Code fully built and wired: apps/api/src/inbox/ module extracted verbatim from DKV (D-01/D-02, verified — fetchPdfAttachments bodies byte-identical per plan's own diff check); InboxProvider.fetchMessages() added to both ImapProvider and ExchangeInboxProvider; EmailAlertAdapter.fetchTenders() (email-alert.adapter.ts) does a real per-tenant fan-out over prisma.tenderEmailConfig.findMany({where:{isActive:true}}), decrypts creds via CalendarCryptoService, routes to imap/exchange provider, extracts links/title generically (D-04), tags every record with ownerTenantId (D-13). Wired into tenders.module.ts (registered, sourceType:'email-alert' poll config seeded isActive:false — no safe default mailbox, tick-granularity). Normalizer dispatches 'email-alert' through normalizeBag() identically to RSS. IMAP path: 7 mocked unit tests (imap.provider.spec.ts) + adapter-level 21 tests (email-alert.adapter.spec.ts) exercise the full pipeline against mocked ImapFlow. EWS/Exchange path: only 6 mocked-httpntlm.post unit tests (exchange-inbox.provider.spec.ts) — the hand-rolled SOAP body-fetch (getItemBodySoap/fetchMessagesViaEws) has never run against a real Exchange server. This is a documented-fragile code path (project memory: EWS uses NTLM via httpntlm, historically validated live, never via ews-javascript-api) with zero pre-existing test precedent. Live end-to-end proof (real mailbox → tenant-private tender, cross-tenant invisibility, non-garbled EWS body) is 14-03-PLAN.md's Task 4, explicitly deferred to a human operator (checkpoint:human-verify, gate=blocking). Scored PARTIAL: code + IMAP/mock path unit-verified; live EWS/mailbox path not yet exercised — see Human Verification below.
3 Admin can manage per-tenant source poll configuration and the email-alert ingestion mailbox, credentials stored encrypted AES-256-GCM (CONFIG-02, CONFIG-03) ✓ VERIFIED TenderEmailConfig Prisma model (schema.prisma:207-223, per-tenant tenantId @unique, encryptedInboxCreds String?) mirrors DkvModuleConfig. TenderEmailConfigService encrypts/decrypts via CalendarCryptoService (AES-256-GCM, same iv:authTag:ciphertext format as DKV/SMTP — confirmed reused, not reimplemented). Safe-Select pattern confirmed: GET responses never include encryptedInboxCreds, only derived hasPassword/username. GET/PUT /modules/tender-radar/email-config routes exist in tenders.controller.ts (@Roles(ADMIN, SUPER_ADMIN), per-handler role guard, tenantId from auth context). TenderRssFeedSource global CRUD (GET/POST/DELETE /modules/tender-radar/rss-feeds) with a save-time SSRF/denylist guard (tender-rss-feed.service.ts:assertUrlAllowed — rejects non-http(s) schemes, DENYLISTED_PORTALS-matching hostnames, private/loopback hosts). TenderEmailConfigService 6 tests, TenderRssFeedSourceService 14 tests, controller wiring tests all pass. EmailAlertConfigForm.tsx/RssFeedListForm.tsx render both sections on the tender-radar settings page.
4 vergabe24 and aumass shown in UI as "manually monitor" with a direct link, not a silent coverage gap (UI-06) ✓ VERIFIED PORTAL_URLS (source-registry.ts:22-25, TypeScript-enforced Record<(typeof DENYLISTED_PORTALS)[number], string> — a future denylist addition without a URL is a compile error) maps vergabe24→https://www.vergabe24.de, aumass→https://www.aumass.de. New GET /modules/tender-radar/denylisted-portals endpoint (tenders.controller.ts, declared before :id per route-order convention) maps DENYLISTED_PORTALS → {portal,url}[]. CoverageBanner.tsx renders an independent "manuell beobachten"/denylist block (own useEffect/fetch/fail-silent lifecycle, NOT gated behind onlyDoe) with clickable target="_blank" links per portal. 2 new API tests (response shape + route-order guard) + 4 new CoverageBanner.test.tsx tests all pass.
5 Entire module UI (results, filters, saved searches, settings) fully usable in German and English (UI-06) ✓ VERIFIED tenderRadar namespace added to apps/web/src/messages/de.json/en.json (~140 leaf keys, includes all 16 Bundesland labels + all 44 EU CPV division descriptions, both DE/EN). tenderRadar-parity.spec.ts recursively asserts identical sorted key sets between locales + non-empty leaf values — ran live, 3/3 pass (see below). All 10 tender-radar components/pages (page.tsx, ResultsList, FilterPanel, SavedSearchBar, TenderDetail, CoverageBanner, settings/page.tsx, SourceConfigForm, RssFeedListForm, EmailAlertConfigForm) converted to useTranslations('tenderRadar') — confirmed via direct read of CoverageBanner.tsx (uses t('coverage.*') throughout) and grep across the phase's file set found no leftover hardcoded German UI strings (only legitimate placeholder= prop usages and code comments). No language switcher added (D-11, intentionally deferred) — a human_judgment: true item in 14-05-SUMMARY.md's own coverage list, but this is a documented non-goal (absence of a feature), not a gap.

Score: 4/5 truths fully VERIFIED, 1/5 PARTIAL (criterion 2 — code complete and unit-tested, live human verification of the EWS/mailbox path outstanding by design)

Required Artifacts

Artifact Expected Status Details
apps/api/src/inbox/{inbox.module,inbox.types,inbox-provider.interface,imap.provider,exchange-inbox.provider}.ts Shared inbox module extracted from DKV, additive fetchMessages() ✓ VERIFIED All files exist, exported via InboxModule, DkvModule imports InboxModule instead of declaring providers directly (checked dkv.module.ts/dkv.types.ts re-export shim)
apps/api/src/tenders/adapters/rss.adapter.ts + .spec.ts RssAdapter, fixture-tested ✓ VERIFIED Reads real fixtures, catch-per-feed fan-out, 18 tests pass
apps/api/src/tenders/adapters/email-alert.adapter.ts + .spec.ts EmailAlertAdapter, per-tenant fan-out ✓ VERIFIED Real findMany fan-out (not a stub), 21 tests pass; generic link/title extraction confirmed non-portal-specific
apps/api/src/tenders/tender-rss-feed.service.ts / tender-email-config.service.ts Admin CRUD + encryption ✓ VERIFIED Both exist, both wired to controller routes, both tested (14 + 6 tests)
apps/api/src/tenders/source-registry.ts Denylist gate + PORTAL_URLS ✓ VERIFIED DENYLISTED_PORTALS + PORTAL_URLS present, register() still throws DeniedPortalError on match
apps/api/prisma/schema.prisma TenderEmailConfig, TenderRssFeedSource, Tender.ownerTenantId, TenderSourcePollConfig.pollGranularity ✓ VERIFIED All four present, migration 20260723113917_... confirms ownerTenantId added as plain nullable ADD COLUMN (no NOT NULL, no backfill UPDATE statement)
apps/web/.../tender-radar/** (CoverageBanner, RssFeedListForm, EmailAlertConfigForm, settings/page.tsx) Admin UI + denylist transparency ✓ VERIFIED All present, rendering real state (not hardcoded stubs), all tested
apps/web/src/messages/{de,en}.json tenderRadar namespace, DE+EN ✓ VERIFIED Namespace present in both files, key-parity test passes live
From To Via Status Details
RssAdapter.fetchTenders() TenderNormalizerService.normalize() sourceType:'rss' dispatch ✓ WIRED normalizer.service.ts:57-59 case 'rss': return this.normalizeBag(raw)
EmailAlertAdapter.fetchTenders() TenderDedupService.resolve() TenderIngestionService.pollDueSources() fan-out ✓ WIRED Adapter registered + poll config seeded in tenders.module.ts; pollDueSources branches on pollGranularity
TenderDedupService CREATE Tender.ownerTenantId n.ownerTenantId ?? null at create time only ✓ WIRED tender-dedup.service.ts:133 — confirmed UPDATE branch (lines 82-101) never touches ownerTenantId
TendersController.listTenders/getTender buildTenderWhere/inline guard D-13 OR-clause + NotFoundException ✓ WIRED tender-query.builder.ts:143-147 (OR[null,mine], fail-closed on unresolved tenant); tenders.controller.ts:505-510 (same NotFoundException as missing id, no detail leak)
TenderRssFeedSourceService.create/update Save-time SSRF/denylist guard assertUrlAllowed() ✓ WIRED Independent of SourceRegistry's boot-time gate, as required by D-14/Pitfall 3
CoverageBanner.tsx GET /denylisted-portals fetchDenylistedPortals() ✓ WIRED Independent useEffect, renders clickable links, fail-silent
All 10 tender-radar components de.json/en.json tenderRadar namespace useTranslations('tenderRadar') ✓ WIRED Confirmed directly in CoverageBanner.tsx; parity spec passes

Behavioral Spot-Checks / Live Test Run

Full suites run live for this verification (not taken from SUMMARY claims):

Suite Command Result Status
API unit tests cd apps/api && npx vitest run 389/389 passed (31 files) ✓ PASS — matches 14-04-SUMMARY's last-recorded count; 14-05 does not touch API
API typecheck cd apps/api && npx tsc --noEmit -p tsconfig.json clean, no output ✓ PASS
Web unit tests cd apps/web && npx vitest run 151/151 passed (27 files) ✓ PASS — matches 14-05-SUMMARY's claim exactly
Web typecheck cd apps/web && npx tsc --noEmit clean, no output ✓ PASS
i18n key-parity (named) npx vitest run src/messages/tenderRadar-parity.spec.ts 3/3 passed ✓ PASS

Requirements Coverage

Requirement Source Plan Description Status Evidence
INGEST-04 14-02 RSS feed ingestion ✓ SATISFIED RssAdapter + normalizer dispatch + pollGranularity tick-gate, all tested
INGEST-05 14-01, 14-03 Email-alert ingestion via shared inbox module ⚠️ PARTIAL Code/IMAP-mock complete; live EWS/mailbox verification outstanding (human_needed, by design)
CONFIG-02 14-02, 14-03 Per-tenant source poll config + encrypted mailbox config ✓ SATISFIED TenderRssFeedSource CRUD + TenderEmailConfig CRUD, both admin-role-gated, both AES-256-GCM
CONFIG-03 14-05 Bilingual module UI ✓ SATISFIED tenderRadar namespace, key-parity test passes live
UI-06 14-04 Denylisted portals shown as "manually monitor" ✓ SATISFIED denylisted-portals endpoint + CoverageBanner block, tested

No orphaned requirements found — all 5 requirements declared in REQUIREMENTS.md's Phase 14 traceability table are claimed by at least one of the 5 plans.

Anti-Patterns Found

None blocking. Grep for TBD|FIXME|XXX across all 67 files touched in this phase returned zero matches. TODO|HACK|PLACEHOLDER / "placeholder" grep returned only legitimate React placeholder= input-hint props, i18n keys named *Placeholder, and one pre-existing docstring reference to the Phase-10 module placeholder page being replaced (informational, not a current stub). No empty-implementation patterns (return null/return {}/=> {}) found in the phase's adapter/service/component code beyond expected fail-silent catch blocks that are explicitly documented and tested.

D-13 Security-Critical Verification (Detailed)

Per the scope's explicit instruction, this was checked with extra scrutiny:

  1. Write-side tagging: TenderDedupService.resolve()'s CREATE branch (only) sets ownerTenantId: n.ownerTenantId ?? null (tender-dedup.service.ts:133). The UPDATE branch (lines 82-101, triggered on contentHash mismatch for a matched tender) does not reference ownerTenantId at all — confirmed by direct read, matching the documented intent that a tender later also seen on a public source is never retroactively re-hidden.
  2. Read-side filter: buildTenderWhere() (tender-query.builder.ts:139-147) appends OR: [{ownerTenantId:null},{ownerTenantId}] when a tenant is resolved, else {ownerTenantId:null} only (fail-closed) — confirmed by direct read, not just SUMMARY claim.
  3. getTender guard: tenders.controller.ts:505-510 — throws the identical NotFoundException('Tender not found') for both a genuinely-missing id and a cross-tenant-forbidden private tender (no existence-leak).
  4. Migration: 20260723113917_tender_email_config_owner_tenant_id/migration.sql — ALTER TABLE "Tender" ADD COLUMN "ownerTenantId" TEXT; — no NOT NULL, no DEFAULT requiring a value, no UPDATE statement anywhere in the file. Existing rows retain NULL (global visibility unchanged) automatically at the column level; SUMMARY's claim of "verified via \d Tender" could not be independently re-run against a live DB in this verification session (no DB access performed), but the migration SQL itself is unambiguous and requires no such live check to confirm no-backfill.

All four sub-checks pass. This is the phase's most security-sensitive mechanism and it is correctly and testedly implemented on both the write and read paths.

Human Verification Required

  1. 14-03-PLAN.md Task 4 — live EWS/mailbox confirmation (blocking checkpoint, by design)
    • Test: Configure a real portal-alert mailbox in Tessera → Ausschreibungs-Radar → Einstellungen → E-Mail-Alerts, specifically exercising the Exchange/EWS protocol path; activate the email-alert poll config; let a real alert email be ingested on the next scheduler tick.
    • Expected: (a) A real alert email produces a Tender row with ownerTenantId set to the configuring tenant; (b) a different tenant's tender-radar results list does NOT show that tender; (c) the EWS body-fetch returns real, non-empty, non-garbled HTML/text content (not empty strings or escaped-garbage from a SOAP-escaping bug).
    • Why human: No real mailbox/Exchange server is available to automated verification. The EWS/NTLM SOAP path is documented in project memory as historically fragile and has zero test precedent prior to this phase; the new code is only proven against a mocked httpntlm.post, never a live server. This is exactly the item the phase's own SUMMARY (14-03) flags as "NOT ready to close" and explicitly defers to an operator.

Gaps Summary

No code gaps. Every one of the 5 ROADMAP success criteria has direct, first-hand-verified code evidence (not just SUMMARY claims) — all adapter/service/controller/component files were read directly, all Prisma schema/migration changes were inspected directly, and all four test suites (API unit, API typecheck, web unit, web typecheck) plus the specific i18n parity test were run live in this session, matching or exceeding the counts claimed in the SUMMARYs. The single outstanding item (live EWS/real-mailbox confirmation) is not a code gap — it is a deliberately-scoped, blocking human-verification checkpoint that requires infrastructure (a real portal-alert mailbox) unavailable to this or any automated verification pass. It should be tracked and completed by the operator before Phase 14 is considered fully closed, but it does not block the code-quality/completeness assessment of this phase.


Verified: 2026-07-23T12:26:12Z Verifier: Claude (gsd-verifier)