c8bf229524
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>
114 lines
18 KiB
Markdown
114 lines
18 KiB
Markdown
---
|
|
phase: 14-rss-email-alert-ingestion-module-rollout
|
|
verified: 2026-07-23T12:26:12Z
|
|
status: human_needed
|
|
score: 4/5 must-haves verified (criterion 2 partial — code+IMAP/mock verified, live EWS pending human)
|
|
behavior_unverified: 0
|
|
overrides_applied: 0
|
|
human_verification:
|
|
- test: "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."
|
|
expected: "(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."
|
|
why_human: "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 |
|
|
|
|
### 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:
|
|
|
|
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)_
|