Files
tessera-ctl/.planning/phases/13-scraping-adapters-cross-source-dedup/13-06-PLAN.md
T
schalli 74c00166e2 docs(13): create phase plan — scraping adapters + cross-source dedup
6 plans (INGEST-02/03/07, SCHEMA-03) + Nyquist validation.
Core (registry, fingerprint, dedup, TenderSource, backfill) lands
first and is green independent of live scraping (D-01).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 08:24:16 +02:00

106 lines
6.0 KiB
Markdown

---
phase: 13-scraping-adapters-cross-source-dedup
plan: 06
type: execute
wave: 2
depends_on: ["13-01"]
files_modified:
- 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/TenderDetail.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx
autonomous: true
requirements: [SCHEMA-03]
must_haves:
truths:
- "GET /modules/tender-radar/:id liefert tender.sources[] (sourcePortal, sourceUrl, sourceNoticeId) via Prisma include mit."
- "TenderDetail rendert die Liste ALLER Quell-Links eines deduplizierten Tenders (Erfolgskriterium 3, Anzeigeseite)."
- "Fallback: fehlt sources[], wird auf den bestehenden Einzel-sourceUrl-Block zurueckgefallen."
artifacts:
- apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx
key_links:
- "getTender include: { sources } -> API-Client-Typ Tender.sources[] -> TenderDetail Multi-Source-Liste."
---
<objective>
Macht die Multi-Source-Deduplizierung sichtbar (D-03, Erfolgskriterium 3 Anzeigeteil): der Read-Endpoint `getTender` liefert die `TenderSource`-Liste via Prisma-`include` mit, und `TenderDetail` rendert alle Quell-Portale mit Links statt eines einzelnen `sourceUrl`. Hardcodiertes Deutsch (i18n = Phase 14, NICHT hier).
Purpose: Ein deduplizierter Tender erscheint EINMAL in der Trefferliste und zeigt in der Detailansicht Links zu allen seinen Quell-Portalen.
Output: getTender mit sources-include (+ spec), API-Client-Typ + TenderDetail-Liste (+ component test).
</objective>
<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/13-scraping-adapters-cross-source-dedup/13-CONTEXT.md
@.planning/phases/13-scraping-adapters-cross-source-dedup/13-RESEARCH.md
@apps/api/src/tenders/tenders.controller.ts
@apps/web/src/lib/tender-radar-api.ts
@apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx
</context>
<tasks>
<task type="auto">
<name>Task 1: getTender liefert sources[] via include (Read-Surface)</name>
<files>apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.controller.spec.ts</files>
<action>
Erweitere `getTender` (aktuell `prisma.tender.findUnique({ where: { id } })`) um `include: { sources: { select: { sourcePortal: true, sourceUrl: true, sourceNoticeId: true } } }` (13-RESEARCH.md "Read-Endpoint mit Quell-Links"). Gating bleibt `@UseModule('tender-radar')`, global (kein Tenant-Scoping, T-10-13). ACHTUNG Route-Order-Pitfall (MEMORY / bestehender Kommentar): der `@Get(':id')` bleibt NACH allen statischen Routen — nichts an der Reihenfolge aendern, nur den findUnique-Aufruf. Erweitere `tenders.controller.spec.ts`: getTender liefert das sources-Array mit (gemockter Prisma-Return mit sources[]); NotFound-Fall bleibt.
</action>
<verify>
<automated>pnpm --filter @tessera/api test -- tenders.controller</automated>
</verify>
<done>getTender include sources; Response enthaelt sources[]; Route-Order unveraendert; Spec gruen.</done>
</task>
<task type="auto">
<name>Task 2: API-Client-Typ + TenderDetail Multi-Source-Liste</name>
<files>apps/web/src/lib/tender-radar-api.ts, apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx</files>
<action>
Ergaenze im API-Client-Typ (`tender-radar-api.ts`) das `Tender` um `sources?: { sourcePortal: string; sourceUrl: string | null; sourceNoticeId: string }[]`. In `TenderDetail.tsx` ersetze den einzelnen sourceUrl-Block durch eine Liste (13-RESEARCH.md "TenderDetail: Multi-Source-Liste"): wenn `tender.sources?.length`, rendere `<ul>` mit einem `<a>` je Quelle (`target="_blank" rel="noopener noreferrer"` — Sicherheits-Muster, kein dangerouslySetInnerHTML, React escaped Text), Label ueber eine kleine `portalLabel(sourcePortal)`-Map (hardcodiertes Deutsch: z.B. 'doe-opendata'->'DÖE', 'tender24'->'tender24', 'cosinex-dtvp'->'DTVP'). Fallback: fehlt/leer sources[], bestehender Einzel-sourceUrl-Block. Erweitere `TenderDetail.test.tsx`: rendert mehrere Quell-Links bei sources[]; Fallback bei fehlendem sources.
</action>
<verify>
<automated>cd apps/web && npx tsc --noEmit && pnpm --filter web test -- TenderDetail</automated>
</verify>
<done>TenderDetail zeigt Liste aller Quell-Links; Fallback bei fehlendem sources; tsc + component test gruen.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Portal-HTML (Titel/Buyer/URL) -> Frontend | Extern gescrapte Strings landen in der Detailansicht (untrusted). |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-13-06-01 | Tampering (Stored XSS) | Portal-Strings in TenderDetail | medium | mitigate | React rendert als Text (default-escaped), kein dangerouslySetInnerHTML (V5). |
| T-13-06-02 | Tampering | externer Quell-Link | low | mitigate | rel="noopener noreferrer" + target="_blank" (bestehendes Muster). |
| T-13-06-03 | Info Disclosure | Tenant-Leak ueber Read-Endpoint | low | mitigate | @UseModule('tender-radar'), Tender global, kein Tenant-Scoping-Wechsel. |
</threat_model>
<verification>
- `pnpm --filter @tessera/api test -- tenders.controller` gruen (sources im Response).
- `cd apps/web && npx tsc --noEmit && pnpm --filter web test -- TenderDetail` gruen.
- Manuell (13-VALIDATION.md): Detailansicht eines Tenders mit >1 TenderSource zeigt mehrere Links.
</verification>
<success_criteria>
Read-Endpoint liefert sources[]; Detailansicht rendert alle Quell-Links; Fallback fuer Alt-Tender. TrAegt Erfolgskriterium 3 (Anzeige aller Quell-Links).
</success_criteria>
<output>
Create `.planning/phases/13-scraping-adapters-cross-source-dedup/13-06-SUMMARY.md` when done
</output>