docs(14): create phase plan (5 plans, 4 waves)

This commit is contained in:
2026-07-23 12:14:26 +02:00
parent 9bd93ce8ff
commit cf105f0884
6 changed files with 915 additions and 2 deletions
@@ -0,0 +1,205 @@
---
phase: 14-rss-email-alert-ingestion-module-rollout
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/src/tenders/adapters/rss.adapter.ts
- apps/api/src/tenders/adapters/rss.adapter.spec.ts
- apps/api/src/tenders/__fixtures__/service-bund-feed.xml
- apps/api/src/tenders/__fixtures__/subreport-elvis-feed.xml
- apps/api/src/tenders/tender.types.ts
- apps/api/src/tenders/tender-normalizer.service.ts
- apps/api/src/tenders/tender-normalizer.service.spec.ts
- apps/api/src/tenders/tender-rss-feed.service.ts
- apps/api/src/tenders/tender-rss-feed.service.spec.ts
- apps/api/src/tenders/dto/tender-rss-feed.dto.ts
- apps/api/src/tenders/tender-ingestion.service.ts
- apps/api/src/tenders/tender-ingestion.service.spec.ts
- apps/api/src/tenders/tenders.module.ts
- apps/api/src/tenders/tenders.controller.ts
- apps/api/src/tenders/tenders.controller.spec.ts
- apps/api/prisma/schema.prisma
- apps/web/src/lib/tender-radar-api.ts
- apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx
- apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx
autonomous: true
requirements: [INGEST-04, CONFIG-02]
must_haves:
truths:
- "RSS items from service.bund.de and subreport-elvis feed shapes map to RawTenderRecord[] (title/link/guid) and normalize into Tender rows"
- "An admin can add/list/remove global RSS feed URLs; a vergabe24/aumass hostname is rejected at save time (SSRF/denylist guard, D-14)"
- "RSS is polled every tick honoring pollIntervalMin, NOT gated to once per calendar day (D-15)"
artifacts:
- "apps/api/src/tenders/adapters/rss.adapter.ts (fast-xml-parser, multi-feed internal fan-out) + fixture spec"
- "Prisma model TenderRssFeedSource (global) + TenderSourcePollConfig.pollGranularity column + migration"
- "GET/POST/DELETE /modules/tender-radar/rss-feeds admin routes + RssFeedListForm admin UI"
key_links:
- "RssAdapter registered in tenders.module.ts onModuleInit + 'rss' poll config seeded pollGranularity='tick'"
- "pollDueSources branches on pollGranularity: 'tick' sources fetch every tick (D-15)"
- "tender-normalizer normalize() dispatches 'rss' -> normalizeBag()"
---
<objective>
Deliver RSS ingestion end-to-end (INGEST-04): a `RssAdapter` that parses admin-managed feed URLs (D-14) via the already-installed `fast-xml-parser`, an admin CRUD surface for the global feed list with a save-time denylist/SSRF hostname guard, and the `pollGranularity='tick'` gate (D-15) so RSS honors its poll interval instead of the DÖE day-cursor. RSS feeds are a global platform config (D-08 — public, same for all tenants), so RSS records are global (visible to all tenants) — unchanged Tender visibility. Thin RSS fields (empty CPV) inherit Phase 13's dormant fingerprint-dedup deferral — no new dedup mechanism here (D-05).
Purpose: Lowest-risk new source that proves the "add a source" pattern end-to-end and seeds the shared `pollGranularity` mechanism that the email slice (14-03) reuses.
Output: Working RSS adapter + normalizer dispatch, TenderRssFeedSource model + admin CRUD + UI, and a tick-driven poll path.
</objective>
## Phase Goal (MVP user story)
**As a** platform admin, **I want to** register RSS feed URLs (service.bund.de, a subreport-elvis municipality feed), **so that** their tenders appear automatically in the shared results list.
Slice progression: Task 1 proves parsing against real feed shapes (failing-first fixture test), Task 2 makes polling real (schema + CRUD + tick gate + wiring), Task 3 gives admins the UI to manage feeds.
## Artifacts this phase produces
- `apps/api/src/tenders/adapters/rss.adapter.ts`: `RssAdapter implements TenderSourceAdapter`, `sourceType: 'rss'`, `portals: ['rss']`; pure `parseFeed(xml, feedLabel): RawTenderRecord[]`; `fetchTenders()` internal fan-out over `TenderRssFeedSource.findMany({where:{isActive:true}})`.
- Fixtures `service-bund-feed.xml`, `subreport-elvis-feed.xml` (live-captured full XML).
- `SourceType` union extended with `'rss'` (tender.types.ts).
- `normalize()` `case 'rss'` → `normalizeBag()` (tender-normalizer.service.ts).
- Prisma `model TenderRssFeedSource { id, url @unique, label, isActive, createdAt, updatedAt }` (global, no tenantId) + `TenderSourcePollConfig.pollGranularity String @default("day")`.
- `TenderRssFeedSourceService` (list/create/remove) with hostname denylist guard; `TenderRssFeedDto`.
- Controller routes `GET/POST/DELETE /modules/tender-radar/rss-feeds` (`@Roles(ADMIN, SUPER_ADMIN)`).
- Web: `RssFeedListForm.tsx`, `tender-radar-api.ts` RSS feed client fns, settings page "RSS-Feeds" section.
- `pollDueSources` `pollGranularity` branch (tick vs day).
<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
@apps/api/src/tenders/adapters/cosinex.adapter.ts
@apps/api/src/tenders/adapters/cosinex.adapter.spec.ts
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: RssAdapter.parseFeed + 'rss' normalizer dispatch (fixture-first)</name>
<files>apps/api/src/tenders/adapters/rss.adapter.ts, apps/api/src/tenders/adapters/rss.adapter.spec.ts, apps/api/src/tenders/__fixtures__/service-bund-feed.xml, apps/api/src/tenders/__fixtures__/subreport-elvis-feed.xml, apps/api/src/tenders/tender.types.ts, apps/api/src/tenders/tender-normalizer.service.ts, apps/api/src/tenders/tender-normalizer.service.spec.ts</files>
<read_first>
- apps/api/src/tenders/adapters/cosinex.adapter.ts (adapter shape, per-item try/catch, bag ocdsPayload, native-fetch+AbortController convention)
- apps/api/src/tenders/adapters/cosinex.adapter.spec.ts (fixture/mock spec style to mirror)
- apps/api/src/tenders/adapters/doe-opendata.adapter.ts §63-66 (XMLParser config: removeNSPrefix/ignoreAttributes/attributeNamePrefix)
- apps/api/src/tenders/tender.types.ts (SourceType union, RawTenderRecord shape)
- apps/api/src/tenders/tender-normalizer.service.ts §53-64,§125-147 (normalize switch + normalizeBag bag shape {title,buyerName,procedureType,legalFramework,deadlineAt})
- RESEARCH.md "RSS parsing" code example + Pattern 3 (item→bag mapping) + live feed shapes (service.bund.de with pubDate; subreport-elvis without, guid isPermaLink=false)
</read_first>
<behavior>
- parseFeed(serviceBundXml, 'service-bund') returns one record per <item>: sourceType 'rss', sourcePortal 'service-bund', sourceNoticeId = guid (or sha256(link).slice(0,40) when guid absent), sourceUrl = link, publishedAt from pubDate, ocdsPayload bag with title (CDATA-merged) and deadlineAt null.
- parseFeed(subreportElvisXml, 'subreport-neuss') returns records with publishedAt null (no item pubDate) and title from the CDATA <title>, without throwing.
- Malformed/empty XML → [] (never throws). A single item missing <link> is skipped without aborting the rest.
- normalize() with sourceType 'rss' routes through normalizeBag() (title fallback 'Unbenannte Ausschreibung', cpv/region/value null).
</behavior>
<action>
Capture full raw XML from the two live feeds into __fixtures__/service-bund-feed.xml and __fixtures__/subreport-elvis-feed.xml (RESEARCH provides representative shapes; capture the complete documents at build time). Create rss.adapter.ts: a `RssAdapter implements TenderSourceAdapter` with `sourceType: 'rss'` and `portals = ['rss'] as const` (symbolic — actual hostnames are runtime admin data, see Task 2 guard). Add a pure `parseFeed(xml: string, feedLabel: string): RawTenderRecord[]` using a module-level XMLParser configured exactly like doe-opendata.adapter.ts; normalize `channel.item` to an array; map each item to a RawTenderRecord with the bag ocdsPayload `{title, buyerName: null, procedureType: null, legalFramework: null, deadlineAt: null}` (title/link/guid are the guaranteed baseline; leave buyerName/deadlineAt null — the optional service.bund.de description enrichment is out of scope for this task). Extend SourceType in tender.types.ts to add `'rss'`. In tender-normalizer.service.ts add `case 'rss':` to the normalize() switch returning `this.normalizeBag(raw)`, and add a fixture/unit test case in tender-normalizer.service.spec.ts asserting an 'rss' bag maps title through and leaves cpvDivisions=[]. Create rss.adapter.spec.ts mirroring cosinex.adapter.spec.ts (readFileSync fixtures, pure parseFeed assertions). Extract link/guid → sourceNoticeId using createHash('sha256') fallback. Text-only extraction (never carry raw HTML into records).
</action>
<verify>
<automated>cd apps/api && npx vitest run src/tenders/adapters/rss.adapter.spec.ts src/tenders/tender-normalizer.service.spec.ts && npx tsc --noEmit -p tsconfig.json</automated>
</verify>
<acceptance_criteria>
- rss.adapter.spec.ts passes with assertions against BOTH fixture shapes (pubDate-present and pubDate-absent).
- `grep -n "'rss'" apps/api/src/tenders/tender.types.ts` shows the union member; normalize() 'rss' case present.
- Empty-XML and missing-link cases return [] / skip without throwing.
</acceptance_criteria>
<done>parseFeed maps both real RSS feed shapes to RawTenderRecord[] and 'rss' records normalize via normalizeBag, all fixture-proven.</done>
</task>
<task type="auto">
<name>Task 2: TenderRssFeedSource model + CRUD service (denylist guard) + tick-gate + wiring</name>
<files>apps/api/prisma/schema.prisma, apps/api/src/tenders/tender-rss-feed.service.ts, apps/api/src/tenders/tender-rss-feed.service.spec.ts, apps/api/src/tenders/dto/tender-rss-feed.dto.ts, apps/api/src/tenders/adapters/rss.adapter.ts, apps/api/src/tenders/tender-ingestion.service.ts, apps/api/src/tenders/tender-ingestion.service.spec.ts, apps/api/src/tenders/tenders.module.ts</files>
<read_first>
- apps/api/prisma/schema.prisma §416-424 (TenderSourcePollConfig) + §309-321 (model style)
- apps/api/src/tenders/source-registry.ts §12 (DENYLISTED_PORTALS — reuse the same constant/spirit for the hostname guard)
- apps/api/src/tenders/tender-ingestion.service.ts §98-184 (pollDueSources loop, nextDayToFetch gate to branch on pollGranularity)
- apps/api/src/tenders/tenders.module.ts §129-214 (onModuleInit register + poll-config upsert seeds)
- apps/api/src/tenders/tender-ingestion.service.spec.ts (existing tick tests to extend)
- CLAUDE.md memory: local migrations run from host via container-IP + tessera:tessera_dev (do NOT restart local services)
- RESEARCH.md Pitfall 1 (day-cursor gate) + Pitfall 3 (runtime feed URL bypasses code denylist) + Security Domain (SSRF)
</read_first>
<action>
Add Prisma `model TenderRssFeedSource { id String @id @default(uuid()); url String @unique; label String; isActive Boolean @default(true); createdAt DateTime @default(now()); updatedAt DateTime @updatedAt }` (global — NO tenantId, mirrors the TenderSourcePollConfig global stance). Add `pollGranularity String @default("day")` to TenderSourcePollConfig. Generate the migration and apply it from the host per the CLAUDE.md container-IP convention (do not restart services). Create tender-rss-feed.service.ts with `list()`, `create(dto)`, `remove(id)`: create/update MUST validate the URL — scheme is http/https, hostname does not contain any DENYLISTED_PORTALS entry (import the constant from source-registry.ts), and reject private/loopback hosts (SSRF, D-14/Pitfall 3); throw BadRequestException on violation. Create dto/tender-rss-feed.dto.ts (`@IsUrl` url, `@IsString` label, `@IsOptional @IsBoolean isActive`). Wire RssAdapter.fetchTenders() to read `TenderRssFeedSource.findMany({where:{isActive:true}})` and fan out (per-feed try/catch, skip-on-error like NetServer/cosinex; native fetch + AbortController 15s + response-size ceiling). In tender-ingestion.service.ts pollDueSources: branch on `config.pollGranularity` — `'day'` keeps the existing nextDayToFetch path UNCHANGED (zero regression for doe/netserver/cosinex); `'tick'` calls `adapter.fetchTenders(berlinToday)` unconditionally each tick and does NOT advance/consult lastIngestedDay (D-15). Add a tender-ingestion.service.spec.ts case proving a 'tick' source is fetched on a tick where a 'day' source is gated out. In tenders.module.ts: add TenderRssFeedSourceService + RssAdapter to providers, register RssAdapter in onModuleInit, and upsert a 'rss' TenderSourcePollConfig seed with `pollGranularity: 'tick', isActive: true`; also upsert a default active service.bund.de TenderRssFeedSource row (RESEARCH Open Question 3: national feed is a safe default; seed zero subreport-elvis rows).
</action>
<verify>
<automated>cd apps/api && npx vitest run src/tenders/tender-rss-feed.service.spec.ts src/tenders/tender-ingestion.service.spec.ts && npx tsc --noEmit -p tsconfig.json</automated>
</verify>
<acceptance_criteria>
- Migration adds TenderRssFeedSource + TenderSourcePollConfig.pollGranularity; `npx prisma validate` passes.
- tender-rss-feed.service.spec.ts asserts a vergabe24.de and an aumass.de URL are both rejected at create, and a valid service.bund.de URL is accepted.
- tender-ingestion.service.spec.ts asserts a pollGranularity='tick' config is fetched on a tick where a day-config with today's lastIngestedDay is skipped.
- tenders.module.ts registers RssAdapter and seeds the 'rss' poll config with pollGranularity 'tick'.
</acceptance_criteria>
<done>Global RSS feed list is admin-CRUD-able with a save-time denylist/SSRF guard, RSS polls every tick per D-15, and the adapter is registered + seeded.</done>
</task>
<task type="auto">
<name>Task 3: RSS feed admin routes + client + RssFeedListForm settings section</name>
<files>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/settings/components/RssFeedListForm.tsx, apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx</files>
<read_first>
- apps/api/src/tenders/tenders.controller.ts §140-157,§371-404 (source-config route pattern, @Roles guard, route-order-before-:id pitfall)
- apps/api/src/tenders/tenders.controller.spec.ts (existing route tests to extend)
- apps/web/src/lib/tender-radar-api.ts §86-117 (fetch client convention, credentials:'include')
- apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx (admin form style to mirror)
- apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx (where to add the "RSS-Feeds" section)
- CLAUDE.md memory: NestJS static routes MUST be declared before @Get(':id') to avoid 404-shadowing
</read_first>
<action>
Add controller routes `GET /modules/tender-radar/rss-feeds` (list), `POST /modules/tender-radar/rss-feeds` (create), `DELETE /modules/tender-radar/rss-feeds/:feedId` (remove), all `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` and delegating to TenderRssFeedSourceService. Declare the static `rss-feeds` routes BEFORE the existing `@Get(':id')` handler (route-order pitfall). Extend tenders.controller.spec.ts asserting the routes are Roles-guarded and that a denylisted feed URL POST surfaces a 400. In tender-radar-api.ts add `RssFeedSource` type + `listRssFeeds()`, `createRssFeed(payload)`, `deleteRssFeed(id)`. Create RssFeedListForm.tsx (client component): loads feeds on mount, renders the list with a remove button per row and an add form (url + label + isActive), surfaces the backend 400 denylist error inline. Add an "RSS-Feeds" section to settings/page.tsx rendering <RssFeedListForm/> below the existing SourceConfigForm (D-09: extend the existing tender-radar settings page, do not build a new one). Keep hardcoded German strings for now — i18n is Plan 14-05.
</action>
<verify>
<automated>cd apps/api && npx vitest run src/tenders/tenders.controller.spec.ts && npx tsc --noEmit -p tsconfig.json && cd ../web && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- Controller exposes GET/POST/DELETE rss-feeds, all Roles-guarded, declared before `@Get(':id')`.
- tenders.controller.spec.ts covers the new routes incl. the denylist-rejection path.
- RssFeedListForm renders in the settings page and web `tsc --noEmit` is clean.
</acceptance_criteria>
<done>An admin can list, add (with denylist rejection), and remove global RSS feeds from the tender-radar settings page, backed by Roles-guarded routes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Admin form → RSS feed URL storage | admin-supplied URL becomes a server-side fetch target |
| Tessera API → arbitrary RSS host | outbound fetch of admin-controlled URL |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-14-02-01 | Tampering/Info Disclosure | RSS feed URL save | high | mitigate | Save-time hostname guard: reject DENYLISTED_PORTALS (vergabe24/aumass) + private/loopback + non-http(s) (D-14, SSRF, Pitfall 3) |
| T-14-02-02 | Denial of Service | RssAdapter fetch | medium | mitigate | AbortController 15s timeout + response-size ceiling (admin URL is less trusted than a hardcoded portal) |
| T-14-02-03 | Tampering (stored XSS) | RSS item description | medium | mitigate | Store only extracted plain-text title + href strings; never persist/render raw feed HTML |
| T-14-02-04 | Elevation of Privilege (IDOR) | rss-feeds routes | high | mitigate | `@Roles(ADMIN, SUPER_ADMIN)` on all rss-feeds routes; global config is a platform-admin action |
| T-14-02-05 | Tampering | day-cursor throttle regression | high | mitigate | pollGranularity branch leaves 'day' path byte-unchanged; only 'tick' sources use the new path (D-15) |
| T-14-02-SC | Tampering | npm/pip/cargo installs | low | accept | No new packages — fast-xml-parser/cheerio already installed (RESEARCH Package Legitimacy Audit) |
</threat_model>
<verification>
- `cd apps/api && npx vitest run && npx tsc --noEmit -p tsconfig.json` — full API suite + new specs green.
- `cd apps/web && npx tsc --noEmit` — clean.
- `npx prisma validate` — schema valid; migration applied from host per CLAUDE.md.
</verification>
<success_criteria>
- RSS feeds parse into normalized Tender rows (INGEST-04) and are polled on interval (D-15).
- Admin can manage the global feed list; denylisted hostnames rejected at save (D-14, CONFIG-02).
- RSS tenders stay global (no visibility change).
</success_criteria>
<output>
Create `.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-02-SUMMARY.md` when done.
</output>