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>
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 |
|
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 |
Key Link Verification
| 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:
- Write-side tagging:
TenderDedupService.resolve()'s CREATE branch (only) setsownerTenantId: n.ownerTenantId ?? null(tender-dedup.service.ts:133). The UPDATE branch (lines 82-101, triggered oncontentHashmismatch for a matched tender) does not referenceownerTenantIdat all — confirmed by direct read, matching the documented intent that a tender later also seen on a public source is never retroactively re-hidden. - Read-side filter:
buildTenderWhere()(tender-query.builder.ts:139-147) appendsOR: [{ownerTenantId:null},{ownerTenantId}]when a tenant is resolved, else{ownerTenantId:null}only (fail-closed) — confirmed by direct read, not just SUMMARY claim. - getTender guard:
tenders.controller.ts:505-510— throws the identicalNotFoundException('Tender not found')for both a genuinely-missing id and a cross-tenant-forbidden private tender (no existence-leak). - Migration:
20260723113917_tender_email_config_owner_tenant_id/migration.sql—ALTER TABLE "Tender" ADD COLUMN "ownerTenantId" TEXT;— noNOT NULL, noDEFAULTrequiring a value, noUPDATEstatement anywhere in the file. Existing rows retainNULL(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
- 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-alertpoll config; let a real alert email be ingested on the next scheduler tick. - Expected: (a) A real alert email produces a
Tenderrow withownerTenantIdset 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.
- Test: Configure a real portal-alert mailbox in Tessera → Ausschreibungs-Radar → Einstellungen → E-Mail-Alerts, specifically exercising the Exchange/EWS protocol path; activate the
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)