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