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,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>