Files
tessera-ctl/.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-02-PLAN.md
T

18 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
14-rss-email-alert-ingestion-module-rollout 02 execute 1
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
true
INGEST-04
CONFIG-02
truths artifacts key_links
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)
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
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()
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.

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>

@.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 Task 1: RssAdapter.parseFeed + 'rss' normalizer dispatch (fixture-first) 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/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) - parseFeed(serviceBundXml, 'service-bund') returns one record per : 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