---
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.