--- phase: 14-rss-email-alert-ingestion-module-rollout plan: 04 type: execute wave: 3 depends_on: [14-03] files_modified: - apps/api/src/tenders/source-registry.ts - apps/api/src/tenders/tenders.controller.ts - apps/api/src/tenders/tenders.controller.spec.ts - apps/web/src/lib/tender-radar-api.ts - apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx - apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx autonomous: true requirements: [UI-06] must_haves: truths: - "vergabe24 and aumass are shown in the UI as 'manuell beobachten' with a working direct link, not a silent coverage gap" - "The denylisted-portal list is sourced from the code-level DENYLISTED_PORTALS constant, not hardcoded twice" artifacts: - "GET /modules/tender-radar/denylisted-portals returning {portal, url} for vergabe24 + aumass" - "CoverageBanner denylist block + CoverageBanner.test.tsx" key_links: - "denylisted-portals endpoint reads DENYLISTED_PORTALS (source-registry.ts) as single source of truth" - "CoverageBanner fetches the endpoint via tender-radar-api.ts and renders each portal with a Direktlink" --- Make the two AGB-prohibited portals (vergabe24, aumass) transparent in the UI as "manuell beobachten" with a direct link (UI-06/D-12), instead of them being an invisible coverage gap. The denylist stays the code-level `DENYLISTED_PORTALS` constant; a small read endpoint exposes it (with canonical URLs) so the frontend never hardcodes the list a second time. Purpose: Turn a silent gap into an explicit, actionable hint — the user-frust trigger this phase closes. Output: A denylisted-portals read endpoint sourced from the existing constant, plus a CoverageBanner block rendering each portal with a direct link. ## Phase Goal (MVP user story) **As a** user, **I want to** see that vergabe24 and aumass must be watched manually (with a direct link), **so that** I understand they are intentionally excluded rather than missing by mistake. Slice: Task 1 exposes the denylist from the existing constant via a read endpoint; Task 2 renders it in CoverageBanner. After Task 1 the data is fetchable; after Task 2 the user sees and can click it. ## Artifacts this phase produces - `PORTAL_URLS` (or equivalent) mapping in source-registry.ts: canonical URL per denylisted portal (`vergabe24` → https://www.vergabe24.de, `aumass` → https://www.aumass.de), keeping `DENYLISTED_PORTALS` the single source of the portal set. - Controller route `GET /modules/tender-radar/denylisted-portals` (`@UseModule('tender-radar')`) → `{ portals: [{ portal, url }] }`, declared before `@Get(':id')`. - Web: `DenylistedPortal` type + `fetchDenylistedPortals()` in tender-radar-api.ts; CoverageBanner denylist block; `CoverageBanner.test.tsx`. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-CONTEXT.md @.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-RESEARCH.md Task 1: denylisted-portals read endpoint sourced from DENYLISTED_PORTALS apps/api/src/tenders/source-registry.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.controller.spec.ts - apps/api/src/tenders/source-registry.ts §12 (DENYLISTED_PORTALS constant — single source of truth) - apps/api/src/tenders/tenders.controller.ts §159-188 (getCoverage read-endpoint shape, @UseModule) + §342-369 (route-order-before-:id) - apps/api/src/tenders/tenders.controller.spec.ts (route tests to extend) - RESEARCH.md UI-06 row + D-12 (confirmed URLs vergabe24.de / aumass.de; DENYLISTED_PORTALS has no API exposure today) In source-registry.ts add a `PORTAL_URLS: Record` (or a typed helper) giving the canonical https URL for each DENYLISTED_PORTALS entry — vergabe24 → https://www.vergabe24.de, aumass → https://www.aumass.de. Do NOT duplicate the portal set; derive the endpoint output by mapping over DENYLISTED_PORTALS so adding a future denylist entry with a URL flows through automatically. Add controller route `GET /modules/tender-radar/denylisted-portals` gated by `@UseModule('tender-radar')` (a read-surface, not admin-only), returning `{ portals: DENYLISTED_PORTALS.map(p => ({ portal: p, url: PORTAL_URLS[p] })) }`. Declare it before the `@Get(':id')` handler (route-order pitfall). Extend tenders.controller.spec.ts to assert the endpoint returns both portals with their URLs. cd apps/api && npx vitest run src/tenders/tenders.controller.spec.ts && npx tsc --noEmit -p tsconfig.json - `GET /modules/tender-radar/denylisted-portals` returns vergabe24 + aumass each with a canonical URL, derived from DENYLISTED_PORTALS. - Route is declared before `@Get(':id')`; spec covers the response shape. - The portal set is not hardcoded a second time in the controller (it maps over DENYLISTED_PORTALS). The denylist is exposed as a read endpoint sourced from the code-level constant, with canonical direct-link URLs. Task 2: CoverageBanner denylist "manuell beobachten" block apps/web/src/lib/tender-radar-api.ts, apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx - apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx (existing banner, fetch-on-mount, fail-silent pattern) - apps/web/src/lib/tender-radar-api.ts §80-84,§144-150 (CoverageResponse + fetchCoverage client convention) - apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx (web component test style / next-intl mock convention) In tender-radar-api.ts add `DenylistedPortal` (`{ portal: string; url: string }`) and `fetchDenylistedPortals(): Promise<{ portals: DenylistedPortal[] }>` following the existing credentials:'include' fetch convention. In CoverageBanner.tsx add a denylist block: fetch the denylisted portals on mount and, when present, render a "manuell beobachten" section listing each portal with an anchor to its `url` (target/rel safe: `rel="noopener noreferrer"`, `target="_blank"`). This block renders independently of the existing Oberschwelle/Unterschwelle coverage note (do not gate it behind the onlyDoe condition — the manual-watch hint is always relevant). Keep fail-silent on fetch error. Create CoverageBanner.test.tsx asserting the block renders both portals with hrefs to vergabe24.de and aumass.de when the fetch resolves. Keep hardcoded German strings for now — i18n is Plan 14-05. cd apps/web && npx vitest run "src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx" && npx tsc --noEmit - CoverageBanner renders a "manuell beobachten" list with clickable direct links to both denylisted portals. - CoverageBanner.test.tsx passes and asserts both portal hrefs. - The block is not suppressed by the existing onlyDoe coverage-note condition; fetch errors fail silently. Users see vergabe24 and aumass as "manuell beobachten" with working direct links in the CoverageBanner. ## Trust Boundaries | Boundary | Description | |----------|-------------| | API → browser | denylisted-portal list + external URLs surfaced to the client | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-14-04-01 | Tampering | duplicated denylist source | low | mitigate | Endpoint maps over DENYLISTED_PORTALS; portal set is not re-declared client-side | | T-14-04-02 | Tampering (reverse-tabnabbing) | external Direktlink anchors | low | mitigate | `rel="noopener noreferrer"` on the external portal links | | T-14-04-SC | Tampering | npm/pip/cargo installs | low | accept | No new packages | - `cd apps/api && npx vitest run src/tenders/tenders.controller.spec.ts && npx tsc --noEmit -p tsconfig.json` — endpoint + spec green. - `cd apps/web && npx vitest run "src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx" && npx tsc --noEmit` — banner test green. - vergabe24 + aumass shown as "manuell beobachten" with direct links (UI-06/D-12). - Denylist remains single-sourced from the code-level constant. Create `.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-04-SUMMARY.md` when done.