docs(14): create phase plan (5 plans, 4 waves)
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
---
|
||||
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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
## 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`.
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: denylisted-portals read endpoint sourced from DENYLISTED_PORTALS</name>
|
||||
<files>apps/api/src/tenders/source-registry.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.controller.spec.ts</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
In source-registry.ts add a `PORTAL_URLS: Record<string, string>` (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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd apps/api && npx vitest run src/tenders/tenders.controller.spec.ts && npx tsc --noEmit -p tsconfig.json</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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).
|
||||
</acceptance_criteria>
|
||||
<done>The denylist is exposed as a read endpoint sourced from the code-level constant, with canonical direct-link URLs.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: CoverageBanner denylist "manuell beobachten" block</name>
|
||||
<files>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</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd apps/web && npx vitest run "src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx" && npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>Users see vergabe24 and aumass as "manuell beobachten" with working direct links in the CoverageBanner.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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 |
|
||||
| T-14-04-SC | Tampering | npm/pip/cargo installs | low | accept | No new packages |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- vergabe24 + aumass shown as "manuell beobachten" with direct links (UI-06/D-12).
|
||||
- Denylist remains single-sourced from the code-level constant.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-04-SUMMARY.md` when done.
|
||||
</output>
|
||||
Reference in New Issue
Block a user