docs(14-04): complete denylist transparency plan

This commit is contained in:
2026-07-23 14:01:12 +02:00
parent 947325f6ad
commit a4889f8399
4 changed files with 105 additions and 14 deletions
@@ -0,0 +1,88 @@
---
phase: 14-rss-email-alert-ingestion-module-rollout
plan: 04
subsystem: api,web
tags: [nestjs, nextjs, denylist, ui-transparency]
requires:
- phase: 13-05
provides: "DENYLISTED_PORTALS constant + SourceRegistry.register() gate (source-registry.ts)"
provides:
- "PORTAL_URLS map (source-registry.ts) — canonical direct-link URL per denylisted portal"
- "GET /modules/tender-radar/denylisted-portals (declared before @Get(':id'))"
- "CoverageBanner 'manuell beobachten' block + fetchDenylistedPortals() client"
affects: [14-05]
tech-stack:
added: []
patterns:
- "Read endpoint derives output by mapping over an existing code-level constant instead of re-declaring the set (DENYLISTED_PORTALS -> PORTAL_URLS lookup)"
- "CoverageBanner: two independent fetch-on-mount blocks, each fail-silent, combined render gate (nothing renders only when BOTH are empty)"
key-files:
created:
- apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx
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
key-decisions:
- "PORTAL_URLS is typed as Record<(typeof DENYLISTED_PORTALS)[number], string> so TypeScript itself enforces that every denylisted portal has a URL entry (a future denylist addition without a URL is a compile error, not a silent gap)"
- "CoverageBanner's denylist block is NOT gated behind onlyDoe (per plan) — it has its own independent useEffect/fetch/fail-silent lifecycle; the component's outer render-gate was changed from 'if (!coverage) return null' to a combined check so the denylist block can render even when the coverage fetch is slow/failed or onlyDoe is false"
- "denylisted-portals route uses @UseModule('tender-radar') (read-surface, same stance as getCoverage/triage), not @Roles admin-only — the plan explicitly calls this a read-surface"
patterns-established:
- "Portal-set single-sourcing: a second endpoint/consumer maps over the original constant rather than importing/copying the array of portal names"
requirements-completed: [UI-06]
coverage: []
duration: ~25min
completed: 2026-07-23
status: complete
---
# Phase 14 Plan 04: Denylist Transparency (vergabe24/aumass) Summary
**Exposes `DENYLISTED_PORTALS` (source-registry.ts) via a new `GET /modules/tender-radar/denylisted-portals` read endpoint carrying canonical direct-link URLs, and renders it in `CoverageBanner` as an independent "manuell beobachten" block with clickable links — turning the two AGB-prohibited portals from a silent coverage gap into an explicit, actionable hint (UI-06/D-12).**
## Performance
- **Tasks completed:** 2 of 2
- **Files modified:** 6 (1 created, 5 modified)
- **API tests:** 389/389 passing (was 387/387; +2 new: response-shape + route-order guard)
- **Web tests:** 148/148 passing (was 144/144; +4 new: CoverageBanner denylist rendering, independence-from-coverage-note, fail-silent x2)
- **Typecheck:** `apps/api` and `apps/web` both clean (`tsc --noEmit`)
## Accomplishments
- **Task 1:** Added `PORTAL_URLS: Record<(typeof DENYLISTED_PORTALS)[number], string>` to `source-registry.ts` (`vergabe24` → `https://www.vergabe24.de`, `aumass` → `https://www.aumass.de`). The typed `Record` keyed off `DENYLISTED_PORTALS`'s own element type means the portal set is never re-declared — TypeScript itself would fail to compile if a future denylist addition lacked a URL entry. Added `GET /modules/tender-radar/denylisted-portals` to `TendersController`, gated by `@UseModule('tender-radar')` (read-surface, same stance as `getCoverage`), declared before `@Get(':id')` (route-order pitfall) and after `getCoverage` (grouped with the other read-surface routes). Handler maps over `DENYLISTED_PORTALS` and looks up each portal's URL in `PORTAL_URLS`, returning `{ portals: [{ portal, url }] }`. Extended `tenders.controller.spec.ts` with a response-shape assertion (`vergabe24`/`aumass` + their URLs) and a route-declaration-order guard (`getDenylistedPortals` before `getTender`), following the file's existing `describe`/`it` conventions for the other Pitfall-5 static routes.
- **Task 2:** Added `DenylistedPortal`/`DenylistedPortalsResponse` types and `fetchDenylistedPortals()` to `tender-radar-api.ts`, following the existing `credentials: 'include'` fetch convention (same shape as `fetchCoverage`). `CoverageBanner.tsx` now runs a second independent `useEffect`/fetch/fail-silent lifecycle for the denylisted portals, rendering a "Manuell beobachten:" paragraph listing each portal as a clickable link (`target="_blank"`, `rel="noopener noreferrer"`) to its canonical URL. This block is NOT gated behind the existing `onlyDoe` coverage-note condition — the component's outer render-gate changed from "nothing renders unless coverage fetched and onlyDoe" to "nothing renders only when BOTH the coverage note AND the denylist list are empty," so the manual-watch hint stays visible even once more public sources are ingested (onlyDoe becomes false) or if the coverage fetch itself fails. Added `CoverageBanner.test.tsx` (new file — none existed before this plan) with 4 tests: both portal hrefs + link attributes render correctly, the block renders independently when `onlyDoe` is false, a rejected denylisted-portals fetch fails silently while the coverage note still renders, and both fetches failing renders nothing.
## Deviations from Plan
None — plan executed as written. `CoverageBanner.test.tsx` was a net-new file (the plan listed it under `files_modified`, but no prior version existed to modify).
## Task Commits
1. **Task 1** — `28c6c7f` (feat) — `source-registry.ts` PORTAL_URLS + `tenders.controller.ts` denylisted-portals route + `tenders.controller.spec.ts` tests.
2. **Task 2** — `947325f` (feat) — `tender-radar-api.ts` fetchDenylistedPortals + `CoverageBanner.tsx` denylist block + `CoverageBanner.test.tsx` (new).
**Plan metadata:** *(this SUMMARY's own commit — see final commit below)*
_Both tasks are single `feat` commits per the plan's `type="auto"` (non-TDD) declaration._
## Self-Check: PASSED
- `apps/api/src/tenders/source-registry.ts` — FOUND
- `apps/api/src/tenders/tenders.controller.ts` — FOUND
- `apps/api/src/tenders/tenders.controller.spec.ts` — FOUND
- `apps/web/src/lib/tender-radar-api.ts` — FOUND
- `apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx` — FOUND
- `apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx` — FOUND
- Commit `28c6c7f` — FOUND in `git log --oneline --all`
- Commit `947325f` — FOUND in `git log --oneline --all`