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
+20 -2
View File
@@ -464,7 +464,25 @@ Plans:
4. vergabe24 and aumass are shown in the UI as "manually monitor" with a direct link, instead of appearing as a silent coverage gap 4. vergabe24 and aumass are shown in the UI as "manually monitor" with a direct link, instead of appearing as a silent coverage gap
5. The entire module UI (results list, filters, saved searches, settings) is fully usable in both German and English 5. The entire module UI (results list, filters, saved searches, settings) is fully usable in both German and English
**Plans**: TBD **Plans**: 5 plans
**Wave 1** *(parallel — disjoint files)*
- [ ] 14-01-PLAN.md — Inbox-Modul-Extraktion (ImapProvider/ExchangeInboxProvider → apps/api/src/inbox/) + additive fetchMessages() (INGEST-05 prerequisite)
- [ ] 14-02-PLAN.md — RSS-Ingestion-Slice: RssAdapter + globale Feed-Liste-CRUD (Denylist/SSRF-Guard) + pollGranularity-Tick-Gate + Admin-UI (INGEST-04, CONFIG-02)
**Wave 2** *(blocked on 14-01 + 14-02)*
- [ ] 14-03-PLAN.md — E-Mail-Alert-Slice: TenderEmailConfig (pro Mandant, verschlüsselt) + EmailAlertAdapter + per-Mandant Tender-Sichtbarkeit (D-13) + EWS-Human-Verify (INGEST-05, CONFIG-02)
**Wave 3** *(blocked on 14-03)*
- [ ] 14-04-PLAN.md — Denylist-Transparenz: denylisted-portals-Endpoint + CoverageBanner „manuell beobachten"-Block (UI-06)
**Wave 4** *(blocked on 14-02 + 14-03 + 14-04)*
- [ ] 14-05-PLAN.md — i18n-Rollout: tenderRadar-Namensraum (DE/EN) + Komponenten auf useTranslations + Key-Parity-Test (CONFIG-03)
**UI hint**: yes **UI hint**: yes
## Progress ## Progress
@@ -487,4 +505,4 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
| 11. Filter Engine, Results UI & Saved Searches | 6/6 | In Progress| | | 11. Filter Engine, Results UI & Saved Searches | 6/6 | In Progress| |
| 12. Tender Notifications | 4/4 | In Progress| | | 12. Tender Notifications | 4/4 | In Progress| |
| 13. Scraping Adapters & Cross-Source Deduplication | 6/6 | In Progress| | | 13. Scraping Adapters & Cross-Source Deduplication | 6/6 | In Progress| |
| 14. RSS, Email-Alert Ingestion & Module Rollout | 0/TBD | Not started | - | | 14. RSS, Email-Alert Ingestion & Module Rollout | 0/5 | Not started | - |
@@ -0,0 +1,154 @@
---
phase: 14-rss-email-alert-ingestion-module-rollout
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/src/inbox/inbox.module.ts
- apps/api/src/inbox/inbox.types.ts
- apps/api/src/inbox/inbox-provider.interface.ts
- apps/api/src/inbox/imap.provider.ts
- apps/api/src/inbox/exchange-inbox.provider.ts
- apps/api/src/inbox/imap.provider.spec.ts
- apps/api/src/inbox/exchange-inbox.provider.spec.ts
- apps/api/src/dkv/dkv.service.ts
- apps/api/src/dkv/dkv.module.ts
- apps/api/src/dkv/dkv.types.ts
autonomous: true
requirements: [INGEST-05]
must_haves:
truths:
- "DKV's existing fetchPdfAttachments behavior is unchanged after providers move to inbox/ (full API suite + tsc green)"
- "A new fetchMessages(config) method on both providers returns each unread message's subject + html/text body"
artifacts:
- "apps/api/src/inbox/ module exporting ImapProvider, ExchangeInboxProvider, InboxProvider, InboxMessage"
- "apps/api/src/inbox/imap.provider.spec.ts + exchange-inbox.provider.spec.ts (net-new coverage)"
key_links:
- "dkv.service.ts + dkv.module.ts import the two providers from '../inbox/...' instead of './providers/...'"
- "InboxProvider.fetchMessages is the seam the Phase-14 EmailAlertAdapter (Plan 14-03) consumes"
---
<objective>
Extract DKV's IMAP/Exchange inbox providers into a shared `apps/api/src/inbox/` module (D-01) and add an additive `fetchMessages()` method (D-02) that returns whole messages with their subject + HTML/text body. This is the one-time prerequisite refactor that lets the Plan 14-03 email-alert adapter reuse DKV's connection mechanics without any DKV behavior change.
Purpose: Share connection code only — not config (D-03). DKV runs in production and its Exchange/NTLM path is known-fragile ([[project_calendar_ews_fix]]); regression risk must be zero.
Output: `inbox/` module (moved files + new `fetchMessages`), DKV switched to the new import path only, and net-new unit coverage for the new method on both providers.
</objective>
## Phase Goal (MVP user story)
**As a** tenant admin, **I want to** have tenders parsed from my portal-alert mailbox, **so that** I stop missing Unterschwellen-Ausschreibungen that only arrive by email.
This plan is the enabling Wave-1 prerequisite for that story: it creates the shared inbox seam the email-alert slice (14-03) builds on. No user-facing behavior changes here; the user-visible outcome lands in 14-03.
## Artifacts this phase produces
- New module `apps/api/src/inbox/` with: `inbox.module.ts` (provides+exports both providers), `inbox.types.ts` (`InboxConfig`, `InboxAttachment`, `InboxEmail`, new `InboxMessage`), `inbox-provider.interface.ts` (adds `fetchMessages`), `imap.provider.ts`, `exchange-inbox.provider.ts` (both gain `fetchMessages`).
- New interface member `InboxProvider.fetchMessages(config: InboxConfig): Promise<InboxMessage[]>`.
- New type `InboxMessage` = `{ uid: number|string; messageId: string; subject: string; from: string; date: Date; bodyHtml: string | null; bodyText: string }`.
- New spec files `apps/api/src/inbox/imap.provider.spec.ts`, `apps/api/src/inbox/exchange-inbox.provider.spec.ts`.
- DKV re-export shim: `dkv.types.ts` re-exports the inbox types from `../inbox/inbox.types` so existing DKV imports keep compiling.
<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: Move inbox providers into apps/api/src/inbox/ (behavior-preserving)</name>
<files>apps/api/src/inbox/inbox.module.ts, apps/api/src/inbox/inbox.types.ts, apps/api/src/inbox/inbox-provider.interface.ts, apps/api/src/inbox/imap.provider.ts, apps/api/src/inbox/exchange-inbox.provider.ts, apps/api/src/dkv/dkv.service.ts, apps/api/src/dkv/dkv.module.ts, apps/api/src/dkv/dkv.types.ts</files>
<read_first>
- apps/api/src/dkv/providers/inbox-provider.interface.ts (interface to move)
- apps/api/src/dkv/providers/imap.provider.ts (move + relative import fix)
- apps/api/src/dkv/providers/exchange-inbox.provider.ts (move + relative import fix)
- apps/api/src/dkv/dkv.types.ts (InboxConfig/InboxAttachment/InboxEmail live here; §47/§69/§78)
- apps/api/src/dkv/dkv.service.ts (imports at §16-17, injects at §82-83)
- apps/api/src/dkv/dkv.module.ts (declares the 2 providers directly)
- RESEARCH.md "Runtime State Inventory" + "Recommended Project Structure" (confirms only 2 import-path edits + a re-export shim)
</read_first>
<action>
Create apps/api/src/inbox/. Move `InboxConfig`, `InboxAttachment`, `InboxEmail` out of dkv.types.ts into a new inbox.types.ts (verbatim). In dkv.types.ts, replace those three interface declarations with a re-export: `export type { InboxConfig, InboxAttachment, InboxEmail } from '../inbox/inbox.types';` so every existing DKV consumer keeps compiling unchanged. Move inbox-provider.interface.ts, imap.provider.ts, exchange-inbox.provider.ts into inbox/ verbatim; update their relative imports to point at './inbox.types' (interface) and './inbox-provider.interface' (providers). Create inbox.module.ts: a NestJS @Module that declares `ImapProvider`, `ExchangeInboxProvider` as providers and lists both in `exports`. In dkv.module.ts, remove the two direct provider declarations from `providers[]`, add `InboxModule` to `imports[]`, and drop the two `./providers/...` import lines in favor of `import { InboxModule } from '../inbox/inbox.module';` (DkvService still injects ImapProvider/ExchangeInboxProvider — now resolved via the imported+exported providers). In dkv.service.ts, change the two provider import paths from `./providers/...` to `../inbox/...`. Delete the now-empty apps/api/src/dkv/providers/ directory. Do NOT change any provider method body, any InboxConfig field, or any DKV logic — this task is a pure move + import-path swap (D-01/D-02: DKV stays byte-behavior-identical).
</action>
<verify>
<automated>cd apps/api && npx tsc --noEmit -p tsconfig.json && npx vitest run src/dkv</automated>
</verify>
<acceptance_criteria>
- `apps/api/src/dkv/providers/` no longer exists (`test ! -d apps/api/src/dkv/providers`).
- `grep -rn "dkv/providers" apps/api/src` returns no matches.
- `npx tsc --noEmit` exits 0; the full DKV spec set passes unchanged (regression guard for the fragile Exchange/NTLM path).
</acceptance_criteria>
<done>All three inbox files live under apps/api/src/inbox/, DKV imports them via '../inbox/...' + InboxModule, DKV tests and typecheck are green, and no fetchPdfAttachments/InboxConfig semantics changed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add fetchMessages() to InboxProvider + both implementations (net-new coverage)</name>
<files>apps/api/src/inbox/inbox-provider.interface.ts, apps/api/src/inbox/inbox.types.ts, apps/api/src/inbox/imap.provider.ts, apps/api/src/inbox/exchange-inbox.provider.ts, apps/api/src/inbox/imap.provider.spec.ts, apps/api/src/inbox/exchange-inbox.provider.spec.ts</files>
<read_first>
- apps/api/src/inbox/imap.provider.ts (collectPdfParts/streamToBuffer patterns to mirror for body-part walking)
- apps/api/src/inbox/exchange-inbox.provider.ts (getItemSoap/extractAll/splitMessageBlocks/escapeXml helpers to extend)
- apps/api/src/tenders/adapters/cosinex.adapter.spec.ts (fixture/mock spec style to mirror)
- RESEARCH.md Pattern 2 "Additive inbox-provider method" (IMAP findBodyParts sketch; EWS `item:Body` FieldURI + BodyType extraction) and Pitfall 4 (EWS body-fetch is untested-fragile — mock-based coverage now, live human-verify is 14-03)
</read_first>
<behavior>
- InboxMessage type added to inbox.types.ts: `{ uid, messageId, subject, from, date, bodyHtml: string|null, bodyText: string }`.
- IMAP fetchMessages: given a mocked ImapFlow whose bodyStructure has a text/html part and a text/plain part, returns one InboxMessage with bodyHtml from the html part and bodyText from the plain part; marks each processed message \Seen (idempotency for re-polls); honors the same UNSEEN + optional senderFilter search as fetchPdfAttachments; returns [] on connect/search error without throwing.
- EWS fetchMessages: given a mocked httpntlm.post returning a GetItem SOAP body with `<t:Body BodyType="HTML">...</t:Body>`, returns one InboxMessage with bodyHtml populated and bodyText empty; a `BodyType="Text"` response populates bodyText with bodyHtml null; marks the item read (IsRead) like fetchPdfAttachments; returns [] on non-200/error.
</behavior>
<action>
Add `fetchMessages(config: InboxConfig): Promise<InboxMessage[]>` to InboxProvider (inbox-provider.interface.ts) and implement it in both providers WITHOUT touching fetchPdfAttachments (D-02). IMAP: add a `findBodyParts(node)` sibling to collectPdfParts that walks the MIME tree collecting the first `text/html` and first `text/plain` part IDs; reuse the connect/lock/search/fetchAll(envelope+bodyStructure) skeleton, then download the html/text part IDs and streamToBuffer(...).toString('utf8'); mark \Seen after processing each message. EWS: add one `<t:FieldURI FieldURI="item:Body"/>` to a new getItemBodySoap (or extend getItemSoap usage for the messages path), read `BodyType` via extractAttr(block,'t:Body','BodyType') and the body via extractAll(block,'t:Body')[0]; map HTML→bodyHtml, Text→bodyText; reuse markReadSoap for idempotency. Create imap.provider.spec.ts and exchange-inbox.provider.spec.ts mirroring cosinex.adapter.spec.ts's mock style (stub ImapFlow / stub httpntlm.post) — these are the codebase's first inbox-provider tests. Keep all logging credential-free (T-07-03 convention): never log config.username/config.password.
</action>
<verify>
<automated>cd apps/api && npx vitest run src/inbox/imap.provider.spec.ts src/inbox/exchange-inbox.provider.spec.ts && npx tsc --noEmit -p tsconfig.json</automated>
</verify>
<acceptance_criteria>
- Both new spec files exist and pass; each asserts an InboxMessage with a populated body from a mocked provider response.
- `grep -n "fetchMessages" apps/api/src/inbox/inbox-provider.interface.ts apps/api/src/inbox/imap.provider.ts apps/api/src/inbox/exchange-inbox.provider.ts` shows the method on all three.
- fetchPdfAttachments is unmodified (`git diff` shows no edits inside its body) and DKV suite still green.
</acceptance_criteria>
<done>Both providers expose a tested fetchMessages returning subject + html/text body per unread message, idempotent via \Seen/IsRead, with fetchPdfAttachments untouched.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Tessera API → IMAP/EWS mail server | decrypted mailbox credentials + network I/O cross here |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-14-01-01 | Information Disclosure | fetchMessages logging | high | mitigate | Reuse `logger: false` (imapflow) / generic-error (EWS) conventions; never log config.username/password (T-07-03) |
| T-14-01-02 | Denial of Service | IMAP body download | medium | mitigate | Reuse the existing MAX_ATTACHMENT_BYTES/streamToBuffer size ceiling for body-part downloads |
| T-14-01-03 | Tampering | DKV regression via refactor | high | mitigate | Pure move + import-path swap; fetchPdfAttachments body untouched; full DKV suite + tsc gate every task |
| T-14-01-SC | Tampering | npm/pip/cargo installs | low | accept | No new packages — imapflow/httpntlm already installed (RESEARCH Package Legitimacy Audit) |
</threat_model>
<verification>
- `cd apps/api && npx vitest run && npx tsc --noEmit -p tsconfig.json` — full API suite green (DKV regression) + new inbox specs pass.
- `grep -rn "dkv/providers" apps/api/src` — zero matches (move complete).
</verification>
<success_criteria>
- inbox/ module exists and is imported by DKV; DKV behavior unchanged (suite green).
- fetchMessages tested on both providers; fetchPdfAttachments untouched.
- No new external dependency added.
</success_criteria>
<output>
Create `.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-01-SUMMARY.md` when done.
</output>
@@ -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>
@@ -0,0 +1,230 @@
---
phase: 14-rss-email-alert-ingestion-module-rollout
plan: 03
type: execute
wave: 2
depends_on: [14-01, 14-02]
files_modified:
- apps/api/src/tenders/adapters/email-alert.adapter.ts
- apps/api/src/tenders/adapters/email-alert.adapter.spec.ts
- 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-dedup.service.ts
- apps/api/src/tenders/tender-email-config.service.ts
- apps/api/src/tenders/tender-email-config.service.spec.ts
- apps/api/src/tenders/dto/tender-email-config.dto.ts
- apps/api/src/tenders/tender-query.builder.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/EmailAlertConfigForm.tsx
autonomous: false
requirements: [INGEST-05, CONFIG-02]
user_setup:
- service: portal-alert mailbox (IMAP or Exchange/EWS)
why: "Per-tenant inbox that receives portal notification emails; separate from the DKV invoice mailbox"
env_vars: []
dashboard_config:
- task: "Provide mailbox host/port/credentials/folder via the tender-radar Email-Alerts settings form"
location: "Tessera → Ausschreibungs-Radar → Einstellungen → E-Mail-Alerts"
must_haves:
truths:
- "Tenders parsed from a tenant's configured mailbox alert emails appear in that tenant's results list, built on the shared inbox/ module"
- "Email-sourced tenders are visible ONLY to the configuring tenant; existing global tenders remain visible to all (D-13)"
- "Admin can save a per-tenant mailbox config with credentials AES-256-GCM-encrypted; GET never returns the password (D-06/D-07)"
artifacts:
- "apps/api/src/tenders/adapters/email-alert.adapter.ts (per-tenant internal fan-out over inbox fetchMessages) + spec"
- "Prisma model TenderEmailConfig (per-tenant) + Tender.ownerTenantId nullable + migration"
- "GET/PUT /modules/tender-radar/email-config (per-tenant, safe-select) + EmailAlertConfigForm UI"
key_links:
- "EmailAlertAdapter consumes InboxProvider.fetchMessages from apps/api/src/inbox (Plan 14-01)"
- "ownerTenantId threads RawTenderRecord -> NormalizedTenderFields -> dedup create; read query filters on it (D-13)"
- "EmailAlertAdapter registered + 'email-alert' poll config seeded pollGranularity='tick'"
---
<objective>
Deliver email-alert ingestion end-to-end (INGEST-05) on the shared inbox module (14-01): a per-tenant mailbox config (encrypted, D-06/D-07), an `EmailAlertAdapter` that fans out over all active tenant mailboxes (internal fan-out, catch-per-tenant) and extracts links+subject generically (D-04), and the critical per-tenant visibility mechanism (D-13) so one tenant's private alert tenders never leak to others while existing global tenders stay global.
Purpose: Close the Unterschwellen long-tail via portal alert emails without portal-specific parsers (D-04) and without weakening multi-tenant isolation. Thin email fields (empty CPV) inherit Phase 13's dormant fingerprint-dedup deferral — no new dedup mechanism (D-05).
Output: TenderEmailConfig model + service + admin UI, EmailAlertAdapter, ownerTenantId visibility on Tender, and an EWS live-path human-verify checkpoint (Pitfall 4).
</objective>
## Phase Goal (MVP user story)
**As a** tenant admin, **I want to** configure my portal-alert mailbox, **so that** tenders from my alert emails appear in my results list and only mine.
Slice progression: Task 1 proves generic link/subject extraction (failing-first), Task 2 makes ingestion real per-tenant with encrypted config + D-13 write-side visibility, Task 3 adds the read-side visibility filter + admin UI + EWS live human-verify.
## Artifacts this phase produces
- `apps/api/src/tenders/adapters/email-alert.adapter.ts`: `EmailAlertAdapter implements TenderSourceAdapter`, `sourceType:'email-alert'`, `portals:['email-alert']`; pure `extractCandidateLinks(bodyHtml, bodyText)`, `titleFromEmail(subject, bodyText)`, `sourceNoticeIdFor(link)`; `fetchTenders()` internal per-tenant fan-out.
- `SourceType` extended with `'email-alert'`; normalize() `case 'email-alert'` → normalizeBag(); ownerTenantId threaded through NormalizedTenderFields + dedup create.
- Prisma `model TenderEmailConfig` (per-tenant, `tenantId @unique`, `encryptedInboxCreds`, protocol/host/port/encryption/folder/senderFilter/domain/isActive) mirroring DkvModuleConfig; `Tender.ownerTenantId String?` (null=global) + index; migration.
- `TenderEmailConfigService` (safe-select + encrypt via CalendarCryptoService) + `TenderEmailConfigDto`.
- Controller `GET/PUT /modules/tender-radar/email-config` (per-tenant, `@Roles(ADMIN, SUPER_ADMIN)`); listTenders/getTender visibility filter.
- Web: `EmailAlertConfigForm.tsx`, api client email-config fns, settings "E-Mail-Alerts" section.
<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
@.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-01-SUMMARY.md
@.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-02-SUMMARY.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Generic link+subject extraction + 'email-alert' normalizer dispatch (test-first)</name>
<files>apps/api/src/tenders/adapters/email-alert.adapter.ts, apps/api/src/tenders/adapters/email-alert.adapter.spec.ts, 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/inbox/inbox-provider.interface.ts + inbox.types.ts (InboxMessage shape from Plan 14-01: subject, bodyHtml, bodyText)
- apps/api/src/tenders/adapters/cosinex.adapter.spec.ts (pure-function spec style)
- apps/api/src/tenders/tender-normalizer.service.ts §53-64,§125-147 (normalize switch + normalizeBag bag shape)
- RESEARCH.md "Generic link extraction from an alert email body (D-04)" code example (FOOTER_NOISE, MAX_LINKS_PER_EMAIL, cheerio a[href], plaintext regex fallback, titleFromEmail, sourceNoticeIdFor)
- RESEARCH.md Anti-Patterns (no portal-specific/sender-domain branching — D-04 deferred)
</read_first>
<behavior>
- extractCandidateLinks(html, text): from an HTML body returns deduped absolute http(s) hrefs via cheerio, drops footer-noise links (unsubscribe/abmelden/einstellungen/preferences), caps at MAX_LINKS_PER_EMAIL; when html is null falls back to a plaintext URL regex.
- titleFromEmail(subject, text): returns trimmed subject, else first non-empty body line, else 'Unbenannte Ausschreibung'.
- sourceNoticeIdFor(link): stable sha256(link).slice(0,40).
- normalize() with sourceType 'email-alert' routes through normalizeBag() (cpv/region/value null, title preserved).
</behavior>
<action>
Create email-alert.adapter.ts with the three pure helpers exactly per the RESEARCH code example (cheerio for HTML, regex fallback for plaintext, footer-noise filter, link cap). Extend SourceType in tender.types.ts to add `'email-alert'`. Add `case 'email-alert':` to normalize() returning normalizeBag(raw), plus a spec case in tender-normalizer.service.spec.ts. Create email-alert.adapter.spec.ts (pure-function tests: HTML body, plaintext-only body, footer-noise stripping, link cap, subject-vs-first-line title fallback). Do NOT add any sender-domain/portal-specific branching (D-04, deferred). Only plain-text title + href strings are produced — never store or emit raw email HTML (stored-XSS guard).
</action>
<verify>
<automated>cd apps/api && npx vitest run src/tenders/adapters/email-alert.adapter.spec.ts src/tenders/tender-normalizer.service.spec.ts && npx tsc --noEmit -p tsconfig.json</automated>
</verify>
<acceptance_criteria>
- email-alert.adapter.spec.ts covers HTML + plaintext bodies, footer-noise removal, and the link cap.
- `grep -n "'email-alert'" apps/api/src/tenders/tender.types.ts` shows the union member; normalize() 'email-alert' case present.
- No sender-domain conditional exists in the adapter (D-04 restraint).
</acceptance_criteria>
<done>Generic link+subject extraction is proven for HTML and plaintext bodies, and 'email-alert' records normalize via normalizeBag.</done>
</task>
<task type="auto">
<name>Task 2: TenderEmailConfig (encrypted, per-tenant) + ownerTenantId write-side + per-tenant fan-out + wiring</name>
<files>apps/api/prisma/schema.prisma, apps/api/src/tenders/tender-email-config.service.ts, apps/api/src/tenders/tender-email-config.service.spec.ts, apps/api/src/tenders/dto/tender-email-config.dto.ts, apps/api/src/tenders/tender.types.ts, apps/api/src/tenders/tender-normalizer.service.ts, apps/api/src/tenders/tender-dedup.service.ts, apps/api/src/tenders/adapters/email-alert.adapter.ts, apps/api/src/tenders/adapters/email-alert.adapter.spec.ts, apps/api/src/tenders/tenders.module.ts</files>
<read_first>
- apps/api/prisma/schema.prisma §179-198 (DkvModuleConfig template) + §264-302 (Tender model, note NO tenantId today)
- apps/api/src/dkv/dkv.service.ts §95-235 (getConfigForApi safe-select, saveConfig encrypt-preserve, CONFIG_SAFE_SELECT pattern)
- apps/api/src/calendar/crypto.service.ts §43-76 (encrypt/decrypt, iv:authTag:ciphertext)
- apps/api/src/tenders/tender-dedup.service.ts §56-142 (resolve: create data block to thread ownerTenantId; create-only)
- apps/api/src/tenders/tender-mail.service.ts §62,§153 (per-tenant resolution pattern)
- apps/api/src/tenders/tenders.module.ts §98-214 (providers + onModuleInit register/seed; imports SettingsModule — add CalendarModule for crypto)
- RESEARCH.md Pattern 1 (EmailAlertAdapter.fetchTenders per-tenant fan-out sketch, cross-tenant findMany is deliberate — document it) + Pitfall 5 / D-13 (per-tenant visibility) + "Per-tenant config service" code example (EMAIL_CONFIG_SAFE_SELECT)
- CLAUDE.md memory: local migrations from host via container-IP; do not restart services
</read_first>
<action>
Add Prisma `model TenderEmailConfig` mirroring DkvModuleConfig (id, `tenantId @unique`, protocol default 'imap', host?, port?, encryption default 'ssl-tls', folder default 'INBOX', senderFilter?, domain?, isActive default false, `encryptedInboxCreds String?`, timestamps, `@@index([tenantId])`). Add `ownerTenantId String?` to Tender (null=global visible to all; set=only that tenant, D-13) plus `@@index([ownerTenantId])`. Generate + apply the migration from the host (CLAUDE.md convention; existing Tender rows get ownerTenantId=null → stay global, satisfying "MUST NOT change visibility of existing global tenders"). Create tender-email-config.service.ts (inject PrismaService + CalendarCryptoService): `getConfigForApi(tenantId)` returns EMAIL_CONFIG_SAFE_SELECT fields + decrypted username + hasPassword (never the password, T-07-12); `saveConfig(tenantId, dto)` encrypts {username,password} via crypto.encrypt with the DKV preserve-empty semantics. Create dto/tender-email-config.dto.ts (`@IsIn` protocol/encryption, `@IsInt @Min(1) @Max(65535)` port, `@IsOptional @IsEmail` senderFilter — mirrors DkvConfigDto). Thread visibility write-side: add optional `ownerTenantId?: string` to RawTenderRecord and NormalizedTenderFields; assemble() passes it through; EmailAlertAdapter sets it per record = the config's tenantId; tender-dedup.service.ts adds `ownerTenantId: n.ownerTenantId ?? null` to the CREATE data block ONLY (never the update branch — a source later seen globally must not be hidden). Implement EmailAlertAdapter.fetchTenders(): `tenderEmailConfig.findMany({where:{isActive:true}})` (deliberate cross-tenant platform-scheduler read — document in the docstring that it must NOT be forTenant()-wrapped, per RESEARCH Anti-Pattern), per config decrypt creds → pick imap/exchange provider from inbox module → provider.fetchMessages(cfg) → extract candidates tagged with ownerTenantId=cfg.tenantId; wrap each tenant in try/catch (one broken mailbox never blocks others). In tenders.module.ts: import CalendarModule (crypto), add TenderEmailConfigService + EmailAlertAdapter providers, register EmailAlertAdapter in onModuleInit, seed an 'email-alert' TenderSourcePollConfig with `pollGranularity:'tick', isActive:false` (framework ready, activation deferred).
</action>
<verify>
<automated>cd apps/api && npx vitest run src/tenders/tender-email-config.service.spec.ts src/tenders/adapters/email-alert.adapter.spec.ts && npx tsc --noEmit -p tsconfig.json</automated>
</verify>
<acceptance_criteria>
- Migration adds TenderEmailConfig + Tender.ownerTenantId (nullable, default null); `npx prisma validate` passes.
- tender-email-config.service.spec.ts asserts encrypt→decrypt round-trip and that getConfigForApi never returns a password field (only hasPassword).
- email-alert.adapter.spec.ts covers per-tenant fan-out with catch-per-tenant (one mocked mailbox throws, others still return records tagged with their ownerTenantId).
- dedup create writes ownerTenantId; update branch does NOT reference ownerTenantId.
</acceptance_criteria>
<done>Per-tenant encrypted mailbox config + email adapter produce ownerTenantId-tagged Tender rows via the shared inbox providers, without leaking across tenants at write time.</done>
</task>
<task type="auto">
<name>Task 3: email-config routes + D-13 read filter + EmailAlertConfigForm</name>
<files>apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.controller.spec.ts, apps/api/src/tenders/tender-query.builder.ts, apps/web/src/lib/tender-radar-api.ts, apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx, apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx</files>
<read_first>
- apps/api/src/tenders/tenders.controller.ts §56-84 (extractTriageContext for tenantId) + §140-157,§354-404 (route-order, @Roles, per-handler auth-context)
- apps/api/src/tenders/tender-query.builder.ts §35-52 (buildTenderWhere signature + AND composition)
- apps/api/src/dkv/dkv.controller.ts §58-93 (config GET/PUT route shape) + apps/web/.../dkv-fleet/settings/components/InboxConfigForm.tsx (password field, T-07-12 blank-on-load)
- apps/api/src/tenders/tenders.controller.spec.ts (route tests to extend)
- apps/web/src/lib/tender-radar-api.ts §86-117 (client convention)
- RESEARCH.md Pitfall 4 / Assumption A2 (EWS body-fetch untested against a real server → human-verify required before Exchange path is production-ready)
- CLAUDE.md memory: static routes before @Get(':id')
</read_first>
<action>
Add controller routes `GET /modules/tender-radar/email-config` and `PUT /modules/tender-radar/email-config`, both `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, resolving tenantId from the auth context (never the body) and delegating to TenderEmailConfigService; declare them before `@Get(':id')`. Implement D-13 read-side visibility: extend buildTenderWhere with an optional `ownerTenantId?: string` param that pushes `{ OR: [{ ownerTenantId: null }, { ownerTenantId: <tenant> }] }`; in listTenders resolve the requesting tenantId from the auth context and pass it in; in getTender add a guard — if the found tender has a non-null ownerTenantId not equal to the requesting tenant, throw NotFoundException (no cross-tenant detail leak). Extend tenders.controller.spec.ts: email-config routes are Roles-guarded; a tender with ownerTenantId=T1 is absent from T2's list and 404s on T2's detail while a null-owner tender is visible to both. In tender-radar-api.ts add `EmailAlertConfig` type + `fetchEmailConfig()`, `saveEmailConfig(payload)`. Create EmailAlertConfigForm.tsx mirroring DKV's InboxConfigForm (protocol/host/port/encryption/folder/senderFilter/domain/username/password/isActive; password blank on load, only sent when typed — T-07-12). Add an "E-Mail-Alerts" section to settings/page.tsx rendering <EmailAlertConfigForm/> (D-09: extend the existing settings page). Keep hardcoded German strings — i18n is Plan 14-05. Then STOP for the checkpoint below.
</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>
- email-config GET/PUT are Roles-guarded, tenant-scoped from auth context, declared before `@Get(':id')`.
- Controller spec proves D-13: ownerTenantId=T1 tender hidden from T2 list + 404 on T2 detail; null-owner tender visible to both.
- EmailAlertConfigForm renders in settings; web tsc clean.
</acceptance_criteria>
<done>Per-tenant email-config admin surface works, email-sourced tenders are tenant-private on read (D-13), and the plan pauses for live EWS verification.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 4: Human-verify EWS live body-fetch against a real mailbox (Pitfall 4)</name>
<files>apps/api/src/inbox/exchange-inbox.provider.ts</files>
<what-built>Per-tenant email-alert mailbox config + EmailAlertAdapter fetching whole-message bodies via the shared inbox providers (IMAP + Exchange/EWS). The EWS `fetchMessages` body-fetch is new hand-written SOAP against a documented-fragile NTLM path (Pitfall 4) and was only verified against mocked responses.</what-built>
<how-to-verify>
1. In Tessera → Ausschreibungs-Radar → Einstellungen → E-Mail-Alerts, enter a real portal-alert mailbox (try the Exchange/EWS path specifically) and save.
2. Ensure at least one portal alert email with a detail link is in the configured folder, unread.
3. Activate the 'email-alert' poll config (set isActive=true) and let one poll tick run (or trigger it).
4. Confirm in the results list (as that tenant) a new tender appears with the email subject as title and the alert link as source URL; confirm a DIFFERENT tenant does NOT see it.
5. Confirm the EWS body came back non-empty/non-garbled (Pitfall 4 failure mode is silent empty/garbled bodies).
</how-to-verify>
<action>Human-only verification — no code changes. Confirm the EWS/IMAP live path returns real message bodies and that email-sourced tenders are visible only to the configuring tenant. This gates INGEST-05's Exchange path being considered production-ready; automated tests only cover mocked SOAP responses.</action>
<verify>
<human-check>Operator confirms: (a) a real alert email produced a tender for the configuring tenant, (b) a second tenant does not see it, (c) the EWS body was non-empty/non-garbled.</human-check>
</verify>
<resume-signal>Type "approved" or describe the mailbox/body issues observed</resume-signal>
<done>A real portal-alert mailbox (incl. the Exchange/EWS path) is confirmed to produce a tenant-private tender with a correctly-fetched body.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Tenant admin → mailbox credentials | credentials stored per tenant, encrypted at rest |
| Tessera API → tenant IMAP/EWS server | network I/O with decrypted creds |
| Email body → Tender store | untrusted HTML crosses into extraction |
| Tenant A email tender → Tenant B view | cross-tenant visibility boundary (D-13) |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-14-03-01 | Information Disclosure | cross-tenant tender leak | critical | mitigate | ownerTenantId (null=global, set=owner); read filter `OR[null, tenant]`; getTender 404 guard; create-only write (D-13) |
| T-14-03-02 | Information Disclosure | mailbox credential storage | high | mitigate | AES-256-GCM via CalendarCryptoService; safe-select excludes encryptedInboxCreds; password never in GET (T-07-12) |
| T-14-03-03 | Tampering (stored XSS) | alert email HTML body | high | mitigate | Extract only plain-text title + href strings via cheerio `.text()`/href; never store/render raw HTML |
| T-14-03-04 | Information Disclosure | fetchMessages logging | high | mitigate | Reuse credential-free logging conventions from Plan 14-01 |
| T-14-03-05 | Elevation of Privilege (IDOR) | email-config routes | high | mitigate | `@Roles(ADMIN, SUPER_ADMIN)`; tenantId from auth context, never body |
| T-14-03-06 | Spoofing/Tampering | EWS body-fetch fragile path | high | mitigate | Mock-based unit tests + blocking human-verify against a real mailbox (Pitfall 4) |
| T-14-03-SC | Tampering | npm/pip/cargo installs | low | accept | No new packages — cheerio/imapflow/httpntlm already installed |
</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.
- Human-verify checkpoint approved for the EWS live path.
</verification>
<success_criteria>
- Email alert tenders appear for the configuring tenant only (INGEST-05, D-13); existing global tenders unchanged.
- Per-tenant mailbox config encrypted, password never returned (CONFIG-02, D-06/D-07).
- Built on the shared inbox/ module (14-01), no DKV behavior change.
</success_criteria>
<output>
Create `.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-03-SUMMARY.md` when done.
</output>
@@ -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>
@@ -0,0 +1,169 @@
---
phase: 14-rss-email-alert-ingestion-module-rollout
plan: 05
type: execute
wave: 4
depends_on: [14-02, 14-03, 14-04]
files_modified:
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/tenderRadar-parity.spec.ts
- apps/web/src/app/(portal)/modules/tender-radar/page.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx
- apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx
- apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx
- apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx
- apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx
autonomous: true
requirements: [CONFIG-03]
must_haves:
truths:
- "The entire tender-radar UI (results, filters, saved searches, detail, coverage, settings, RSS/email forms) renders via a tenderRadar i18n namespace, usable in DE and EN"
- "de.json and en.json have identical key sets under tenderRadar (no missing translation on either side)"
artifacts:
- "tenderRadar namespace added to de.json + en.json with EN translations authored by Claude"
- "tenderRadar-parity.spec.ts enforcing de/en key-set equality"
key_links:
- "Every tender-radar component/page reads strings via useTranslations('tenderRadar') — no hardcoded German literals remain"
---
<objective>
Complete the bilingual module rollout (CONFIG-03/UI-06 i18n, D-10/D-11): introduce a new `tenderRadar` namespace in de.json + en.json and convert every tender-radar component and page from hardcoded German strings to `useTranslations('tenderRadar')` keys. No visible language switcher (D-11) — locale continues to follow the existing next-intl cookie mechanism. EN translations are authored here with consistent Vergabe-domain terminology.
Purpose: The module was intentionally built with German MVP-stub strings; this is the point that convention is retired for tender-radar specifically.
Output: A complete `tenderRadar` namespace (DE + EN), all components converted, and a de/en key-parity guard test.
</objective>
## Phase Goal (MVP user story)
**As a** user, **I want to** use the entire Ausschreibungs-Radar UI in German or English, **so that** English-speaking colleagues can work with it without a German-only barrier.
Slice: Task 1 lands the namespace + parity guard; Task 2 converts the main results/filter components; Task 3 converts the settings + config forms. After Task 3 the whole module honors the selected locale.
## Artifacts this phase produces
- New top-level `tenderRadar` namespace in `apps/web/src/messages/de.json` and `en.json` (parallel key sets), covering: page headings, ResultsList, FilterPanel, SavedSearchBar, TenderDetail, CoverageBanner (incl. denylist "manuell beobachten" block from 14-04), settings page + SourceConfigForm + RssFeedListForm (14-02) + EmailAlertConfigForm (14-03).
- `apps/web/src/messages/tenderRadar-parity.spec.ts` — structural de/en key-set equality test for the namespace.
- All listed components/pages converted to `useTranslations('tenderRadar')`.
<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/web/src/i18n/request.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: tenderRadar namespace (DE + EN) + de/en key-parity spec</name>
<files>apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/tenderRadar-parity.spec.ts</files>
<read_first>
- apps/web/src/messages/de.json + en.json (top-level namespace shape; dkvFleet namespace as the sibling template for a module namespace)
- apps/web/src/i18n/request.ts (locale from NEXT_LOCALE cookie, default 'de' — no code change needed for D-11)
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx (useTranslations('marketplace') consumption example)
- RESEARCH.md i18n section (D-10 namespace shape, key-parity test recommendation) + D-11 (no switcher)
</read_first>
<action>
Inventory every hardcoded German string across the tender-radar components and pages (page.tsx, ResultsList, FilterPanel, SavedSearchBar, TenderDetail, CoverageBanner, settings/page.tsx, SourceConfigForm, RssFeedListForm, EmailAlertConfigForm). Add a `tenderRadar` object to de.json capturing all of them as keys (group by component, e.g. `results.*`, `filter.*`, `savedSearch.*`, `detail.*`, `coverage.*`, `settings.*`, `rssFeeds.*`, `emailAlerts.*`), preserving the exact current German copy as values. Add the identical key set to en.json with faithful English translations using consistent Vergabe-domain terminology (Ausschreibung → tender, Vergabestelle → contracting authority, Frist → deadline, Auftragswert → estimated value). Create tenderRadar-parity.spec.ts (apps/web, jsdom) that imports both JSON files and asserts the recursively-flattened key sets under `tenderRadar` are identical (fails listing any key present in one locale but not the other). Do NOT add a language switcher (D-11).
</action>
<verify>
<automated>cd apps/web && npx vitest run src/messages/tenderRadar-parity.spec.ts && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- de.json and en.json both contain a `tenderRadar` namespace with identical (recursively-flattened) key sets.
- tenderRadar-parity.spec.ts passes and would fail on any de/en key mismatch.
- No language-switcher UI element is introduced.
</acceptance_criteria>
<done>The tenderRadar namespace exists in both locales with enforced key parity and authored EN translations.</done>
</task>
<task type="auto">
<name>Task 2: Convert results-side components to useTranslations</name>
<files>apps/web/src/app/(portal)/modules/tender-radar/page.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx</files>
<read_first>
- apps/web/src/app/(portal)/modules/tender-radar/page.tsx (headings)
- apps/web/src/app/(portal)/modules/tender-radar/components/{ResultsList,FilterPanel,SavedSearchBar,TenderDetail,CoverageBanner}.tsx (strings to replace)
- apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx + SavedSearchBar.test.tsx + TenderDetail.test.tsx + CoverageBanner.test.tsx (existing tests — update next-intl mock so t(key) resolves; mirror marketplace tenant-selector.test.tsx mock)
- tenderRadar keys added in Task 1
</read_first>
<action>
Convert page.tsx, ResultsList.tsx, FilterPanel.tsx, SavedSearchBar.tsx, TenderDetail.tsx, and CoverageBanner.tsx to read every user-facing string via `const t = useTranslations('tenderRadar')` and `t('...')` (or `t.rich`/interpolation where the current copy contains values). Replace all German literals with their tenderRadar keys from Task 1 — no hardcoded user-facing German text may remain in these files. Update the affected component tests to provide a next-intl `useTranslations` mock (returning the key or a lookup) so they still pass, mirroring the marketplace test mock convention. Preserve behavior and markup — this is a string-source swap only.
</action>
<verify>
<automated>cd apps/web && npx vitest run "src/app/(portal)/modules/tender-radar/components" && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `grep -nP "[A-Za-zÄÖÜäöüß]{4,}" ` audit: no user-facing German string literals remain in the six converted files (strings come from t()).
- Each converted component imports useTranslations('tenderRadar').
- Updated component tests pass; web tsc clean.
</acceptance_criteria>
<done>The results list, filters, saved searches, detail overlay, coverage banner, and page headings all render via the tenderRadar namespace.</done>
</task>
<task type="auto">
<name>Task 3: Convert settings + config forms to useTranslations</name>
<files>apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx, apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx, apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx, apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx</files>
<read_first>
- apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx (headings + digest section strings)
- apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx (labels, validation/save messages)
- apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx (from Plan 14-02)
- apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx (from Plan 14-03)
- apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.test.tsx (update next-intl mock)
- tenderRadar keys added in Task 1
</read_first>
<action>
Convert settings/page.tsx, SourceConfigForm.tsx, RssFeedListForm.tsx, and EmailAlertConfigForm.tsx to `useTranslations('tenderRadar')`, replacing every label, placeholder, button, validation message, and success/error string with its tenderRadar key. For dynamic validation messages that embed numbers (e.g. interval bounds), use next-intl interpolation (`t('...', { min, max })`). Update SourceConfigForm.test.tsx (and any other affected settings test) with the next-intl mock so they pass. No hardcoded user-facing German text may remain in these four files.
</action>
<verify>
<automated>cd apps/web && npx vitest run "src/app/(portal)/modules/tender-radar/settings" src/messages/tenderRadar-parity.spec.ts && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- settings/page.tsx + the three config forms read all user-facing strings via useTranslations('tenderRadar').
- No user-facing German literal remains in these four files.
- Settings tests pass; parity spec still green (any key added here exists in both locales); web tsc clean.
</acceptance_criteria>
<done>The entire tender-radar settings surface (source config, RSS feeds, email alerts, digest) is bilingual.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| (none new) | i18n string-source swap; no new data flow or input surface |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-14-05-01 | Tampering | de/en key drift | low | mitigate | tenderRadar-parity.spec.ts fails the build on any missing/extra key in either locale |
| T-14-05-SC | Tampering | npm/pip/cargo installs | low | accept | No new packages — next-intl already installed |
</threat_model>
<verification>
- `cd apps/web && npx vitest run "src/app/(portal)/modules/tender-radar" src/messages/tenderRadar-parity.spec.ts && npx tsc --noEmit` — all tender-radar web tests + parity green.
- Manual: toggling the NEXT_LOCALE cookie de↔en switches the whole module UI.
</verification>
<success_criteria>
- Entire tender-radar UI usable in DE and EN via the tenderRadar namespace (CONFIG-03, D-10).
- de/en key parity enforced by test; no language switcher added (D-11).
</success_criteria>
<output>
Create `.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-05-SUMMARY.md` when done.
</output>