Files
tessera-ctl/.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-VERIFICATION.md
T
schalli 48d1246043 fix(10): resolve GET /source-config 404 shadowed by :id route
The admin source-config settings form failed to load with "Failed to
fetch tender-radar source config". Network trace showed
GET /modules/tender-radar/source-config returning 404.

Root cause: NestJS RouterExplorer maps routes in method-declaration
order. `@Get(':id')` was declared before `@Get('source-config')`, so
the param route captured "source-config" as an id and shadowed the
static handler (401 unauthenticated, 404 past the guard — no Tender
with id "source-config").

Fix: declare `@Get('source-config')` before `@Get(':id')`. Add a
declaration-order regression test — unit tests call controller methods
directly, bypass routing, and could never catch route shadowing.

Verified live: settings form now loads real config, interval save
persists and live-re-registers the scheduler (INGEST-06). Phase 10
verification raised human_needed -> passed after full browser UAT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:52:17 +02:00

149 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 10-ausschreibungs-radar-foundation-d-e-ingestion
verified: 2026-07-21T12:44:00Z
status: passed
score: 5/5 must-haves verified
behavior_unverified: 0
overrides_applied: 0
re_verification: "2026-07-21T12:44:00Z — Stack rebuilt; live browser UAT run for all 3 human_verification items. Found + fixed a route-order bug (GET /source-config shadowed by GET /:id → 404). Regression test added. All 3 items now pass."
human_verification:
- test: "Als Admin im Marketplace 'Ausschreibungs-Radar' aktivieren (Tenant A), /modules/tender-radar oeffnen und bestaetigen, dass die Seite rendert (kein 404)."
expected: "Seite laedt mit Titel 'Ausschreibungs-Radar' + Platzhaltertext, kein 404."
why_human: "Erfordert einen echten Browser-Roundtrip durch Marketplace-Aktivierung -> lazy-loaded Modulseite. Der lokale API/Web-Container laeuft noch auf dem VOR-Phase-10-Image (siehe Anti-Pattern-Sektion) — TendersModule/TendersController sind im laufenden Container nicht gemappt. Ein Rebuild/Neustart des Stacks liegt laut Projekt-Konvention beim User, nicht beim Verifier."
- test: "Als Admin /modules/tender-radar/settings oeffnen, Intervall aendern, speichern, Seite neu laden und pruefen, dass der neue Wert persistiert wurde (echter GET/PUT-Roundtrip gegen die Plan-05-Route)."
expected: "Formular zeigt den zuvor gespeicherten Wert nach Reload; PUT-Aufruf schlaegt sich in der DB nieder."
why_human: "Component-Tests mocken tender-radar-api.ts vollstaendig (10-06 SUMMARY selbst als human_judgment:true dokumentiert). Kein Live-Container mit dem neuen Code laeuft aktuell, siehe oben."
- test: "Container-Rebuild durchfuehren und pruefen, dass TendersModule beim Boot tatsaechlich onModuleInit() durchlaeuft (Log-Zeile 'Ausschreibungs-Radar module seeded in registry' + 'doe-opendata poll config seeded' + TenderSchedulerService-Log erscheinen; TenderSourcePollConfig-Tabelle enthaelt danach genau 1 Zeile)."
expected: "Nach Rebuild erscheinen alle drei Boot-Logs; TenderSourcePollConfig hat genau 1 Zeile (sourceType='doe-opendata')."
why_human: "Der laufende Container ist aelter als Phase 10 und hat diesen Code nie ausgefuehrt (siehe Anti-Pattern-Sektion) — die Live-DB-Tabelle TenderSourcePollConfig ist aktuell leer (0 Zeilen), obwohl der Code einen Boot-Seed garantiert. Ohne Rebuild ist dies nicht abschliessend pruefbar; kein Docker-Deploy durch den Verifier gemaess Projekt-Konvention."
---
# Phase 10: Ausschreibungs-Radar Foundation & DÖE Ingestion Verification Report
**Phase Goal:** The platform ingests German public tenders from the DÖE OpenData API on a shared, multi-tenant-safe schedule into a normalized, platform-global schema, and the module can be activated per tenant from the marketplace.
**Verified:** 2026-07-21T12:44:00Z
**Status:** passed
**Re-verification:** Yes — live browser UAT after stack rebuild (2026-07-21T12:44Z)
## Re-Verification: Live Browser UAT (2026-07-21T12:44Z)
Stack rebuilt (`docker compose build api web` + `up -d`) — the pre-Phase-10-image blocker from the initial verification is gone. All three `human_verification` items were then run against the live stack:
**Backend end-to-end proof (before UAT):** seeded the day-cursor to a past date and let a real cron tick run — the DÖE OpenData adapter fetched real `eforms.zip` exports (1.8–4.5 MB/day) for 2026-07-17..20 and ingested **1671 real Tender rows** (verified titles: "Tragwerksplanung", "Allradschlepper", "Tiefbauarbeiten", …). `nextDayToFetch` "from-now" gate (Pitfall A) confirmed: a fresh config is a no-op until the next calendar day — expected, not a bug.
1. **Marketplace activation → lazy module page — PASS.** As admin, tenant "Default", activated "Ausschreibungs-Radar" (toast "Modul erfolgreich aktiviert", sidebar category "procurement 1", status → Aktiviert). `/modules/procurement/tender-radar` renders the lazy-loaded page ("Ausschreibungen werden erfasst." placeholder — real results UI is Phase 11 scope). No 404.
2. **Settings-form GET/PUT roundtrip — PASS (after a bug fix).** Initial load showed "Failed to fetch tender-radar source config"; network trace: `GET /api-proxy/modules/tender-radar/source-config → 404`. Root cause: **route-order bug** — `@Get(':id')` was declared before `@Get('source-config')`, so NestJS matched `:id="source-config"` and shadowed the static handler (401 unauthenticated, 404 past the guard: Tender "source-config" not found). Fix: moved `@Get('source-config')` above `@Get(':id')` in `tenders.controller.ts`; added a declaration-order regression test in `tenders.controller.spec.ts` (unit method-calls bypass routing and could never catch this). After rebuild: form loads real config ("Zuletzt erfasster Tag: 20.7.2026"), interval change 60→30 saved ("Einstellungen gespeichert."), persisted in DB, and the scheduler live-re-registered "every 30 minutes" without restart (INGEST-06 confirmed). Interval reset to 60 afterward.
3. **Boot-seed confirmation — PASS.** After rebuild, all three boot logs appear ("Ausschreibungs-Radar module seeded in registry", "doe-opendata poll config seeded", "Tender scheduler initialized"). `TenderSourcePollConfig` holds exactly 1 row (sourceType='doe-opendata'). Note: on a truly first boot the scheduler logs "config inactive — cron job not registered" because scheduler `onModuleInit` can run before the config seed; it self-heals on the next boot / on any admin save. Not a defect, but noted as a minor boot-ordering nicety for a future phase.
**Verdict:** all 5 must-haves + all 3 human-verification items pass. Status raised `human_needed` → `passed`.
---
### (Original initial-verification report below)
**Verified:** 2026-07-21T09:32:05Z
**Status:** human_needed
**Re-verification:** No — initial verification
## Zusammenfassung
Alle 5 ROADMAP-Erfolgskriterien sind auf Code-/Schema-/Testebene durch eigene Nachpruefung (nicht nur SUMMARY-Behauptungen) bestaetigt: Schema ist global (kein `tenantId`), Marketplace-Self-Seed + Web-Whitelist existieren und sind verdrahtet, Content-Hash-Change-Detection ist per echtem Integrationstest bewiesen, der Scheduler ist ein einziger globaler Cron-Job (kein `findFirst`/`activeTenantId`), und der Zwei-Mandanten-Sicherheitstest treibt tatsaechlich den echten `ModuleRegistryService.activateForTenant()`-Pfad (kein Tautologie-Stub). Beide Testsuiten (API 74/74, Web 111/111) sind gruen, `tsc --noEmit` ist sauber in beiden Apps, die Migration ist live in der DB angewendet.
Der einzige Befund, der den Status von `passed` auf `human_needed` senkt: der lokale Docker-Stack laeuft noch auf dem **VOR-Phase-10-Image** — `TendersModule`/`TendersController` sind im aktuell laufenden Container nicht registriert (kein `TendersController`-Routen-Log, keine Seed-Log-Zeile), und `TenderSourcePollConfig` ist in der Live-DB leer (0 Zeilen), obwohl der Code einen garantierten Boot-Seed vorsieht. Das ist kein Code-Defekt — es ist exakt die vom Executor selbst dreimal dokumentierte Luecke (10-02 D2/D3, 10-06 D3: "human_judgment: true ... Rebuild liegt beim User"). Es bedeutet aber, dass der End-to-End-Browser-Roundtrip (Marketplace-Aktivierung -> Seite laedt; Settings-Formular -> Persistenz) bislang nirgends tatsaechlich beobachtet wurde — weder von Claude noch vom User. Das gehoert vor Phase-11-Start in eine kurze manuelle UAT-Runde.
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Admin findet "Ausschreibungs-Radar" im Marketplace und kann es pro Mandant aktivieren (wie DKV/Cert-Manager) | ✓ VERIFIED (Code) / siehe Human-Item 1 (Live) | `tenders.seed.ts` seedt `slug:'tender-radar', isSystem:true`; `app.module.ts` importiert `TendersModule` (grep=2); Marketplace-Seite laedt Module generisch via `GET /modules` (kein hardcodierter Modul-Katalog) — `apps/web/src/app/(portal)/marketplace/page.tsx:55`; `module-loader.ts` hat den Pflicht-Whitelist-Eintrag `'tender-radar'` (Zeile 50-54); `tender-radar/page.tsx` existiert und default-exportiert. `tenders.seed.spec.ts` (1 Test) gruen |
| 2 | Neue DÖE-Ausschreibungen erscheinen als normalisierte Records (Titel, Buyer, CPV, Region/PLZ, Frist, Wert, Verfahrensart, Quell-URL) nach einem Poll — plattform-global, nicht mandantengebunden | ✓ VERIFIED | Live-Schema-Introspektion via Prisma (`information_schema.columns`) bestaetigt: `Tender`-Tabelle hat **keine** `tenantId`-Spalte, alle geforderten Felder vorhanden (`title, buyerName, cpvCodes, region, plz, deadlineAt, estimatedValue, procedureType, sourceUrl`). `DoeOpenDataAdapter`/`TenderNormalizerService` gegen ECHTE, live von oeffentlichevergabe.de gezogene Fixture-ZIPs (51KB/16KB, 8 reale Notices) getestet — 6/6 + 6/6 Tests gruen, inkl. exakter D-02-Filter-Zaehlung (4 von 8) und eForms-primaerer Deadline-Wiederherstellung wo OCDS null ist |
| 3 | Eine geaenderte DÖE-Ausschreibung aktualisiert den bestehenden Record via Content-Hash statt eines Duplikats (SCHEMA-02) | ✓ VERIFIED | `tender-ingestion.service.ts`: `prisma.tender.upsert({ where: { dedupKey } })`; `tender-ingestion.service.spec.ts` — expliziter Test "updates the existing row in place when the same dedupKey reappears with a changed contentHash" pruft Store-Groesse bleibt 1 UND geaenderte Felder werden uebernommen; separater Test fuer identische Notiz (kein Duplikat). Beide gruen |
| 4 | DÖE wird einmal auf einem gemeinsamen, admin-konfigurierbaren Intervall gepollt, unabhaengig von der Anzahl aktiver Mandanten (poll-once-fan-out-many, nicht DKV-`findFirst()`) | ✓ VERIFIED | `tender-scheduler.service.ts`: genau EIN Cron-Job (`JOB_NAME='tender-doe-poll'`), `onModuleInit()` nutzt `findUnique({where:{sourceType}})` (kein `findFirst`), `setInterval(intervalMin)` hat KEIN Tenant-Argument. Grep bestaetigt: `activeTenantId` = 0 Treffer, `findFirst` = 0 Treffer, `findUnique` >= 1. Admin-UI (`SourceConfigForm` + `PUT /source-config`) treibt `scheduler.setInterval()`/`stopJob()` live ohne Neustart — bewiesen durch `tenders.controller.spec.ts` |
| 5 | Aktivierung fuer einen 2. Mandanten dupliziert Ingestion nicht, triggert keinen redundanten Poll, beeinflusst Mandant-1-Daten nicht | ✓ VERIFIED | `tender-scheduler.service.spec.ts` treibt den ECHTEN (nicht gemockten) `ModuleRegistryService.activateForTenant()` gegen ein Fake-Prisma — eigene Nachpruefung von `activateForTenant()` im Quellcode bestaetigt: die Funktion beruehrt ausschliesslich `module`/`tenantModuleActivation`-Tabellen, NICHTS DÖE-Spezifisches. Test asserts nach 2. Aktivierung: `addCronJob` weiterhin 1x, `pollDueSources` 0x zusaetzlich aufgerufen, `tender.count()` bleibt 0 — echter Integrationsbeweis, keine Tautologie |
**Score:** 5/5 truths verified (0 present, behavior-unverified)
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/prisma/schema.prisma` (Tender, TenderSourcePollConfig) | Global Tender-Tabelle, Singleton-Config | ✓ VERIFIED | Live-DB-Introspektion bestaetigt Spalten + Fehlen von `tenantId`; `prisma migrate status` -> "Database schema is up to date!" |
| `apps/api/src/tenders/tenders.module.ts` | Self-seeding Modul, Provider-Registrierung | ✓ VERIFIED | `OnModuleInit` ruft `seedTendersModule()` + upsert des Singleton-Configs; alle Services in `providers` |
| `apps/api/src/tenders/tenders.seed.ts` | Marketplace-Seed | ✓ VERIFIED | `slug:'tender-radar', isSystem:true`, bilinguale Beschreibung |
| `apps/web/src/lib/module-loader.ts` | Whitelist-Eintrag `tender-radar` | ✓ VERIFIED | Zeile 50-54, `dynamic(() => import(...))` |
| `apps/web/src/app/(portal)/modules/tender-radar/page.tsx` | Modul-Seite (MVP-Stub) | ✓ VERIFIED | Existiert, default export, dokumentierter Platzhalter fuer Phase 11 |
| `apps/api/src/tenders/adapters/doe-opendata.adapter.ts` | Fetch/Extract/Parse/D-02-Filter | ✓ VERIFIED | Echte Fixtures, exakte Zaehl-Assertion (4/8), Zip-Bomb-Ceiling-Test gruen |
| `apps/api/src/tenders/tender-normalizer.service.ts` | Feld-Mapping, dedupKey, contentHash | ✓ VERIFIED | eForms-primaere Deadline-Wiederherstellung bewiesen |
| `apps/api/src/tenders/tender-ingestion.service.ts` | Day-Cursor-Gate, Upsert, Retention | ✓ VERIFIED | 7 Tests gruen inkl. No-Op-Gate, SCHEMA-02, D-05-Retention (Null-Deadline nie geloescht) |
| `apps/api/src/tenders/tender-scheduler.service.ts` | Einziger globaler Cron | ✓ VERIFIED | Grep-Gates gruen (`activeTenantId`=0, `findFirst`=0) |
| `apps/api/src/tenders/tenders.controller.ts` | Global Read (ModuleGuard) + Admin Roles | ✓ VERIFIED | `@UseModule('tender-radar')` auf GET-Routen, `@Roles(ADMIN,SUPER_ADMIN)` auf beiden Source-Config-Routen, kein `tenantId` im Read-Where |
| `apps/web/src/lib/tender-radar-api.ts` + `SourceConfigForm.tsx` + `settings/page.tsx` | Admin-UI fuer Poll-Intervall | ✓ VERIFIED | `fetchSourceConfig`/`saveSourceConfig` vorhanden, Formular mit Bounds 5-1440, 4 Component-Tests gruen |
### Key Link Verification
| From | To | Via | Status | Details |
|------|-----|-----|--------|---------|
| `TendersModule.onModuleInit()` | `ModuleRegistryService.seedModule()` | direkter Aufruf | ✓ WIRED | Code bestaetigt, Test bestaetigt Call-Shape |
| `module-loader.ts` MODULE_REGISTRY['tender-radar'] | `tender-radar/page.tsx` | `dynamic(() => import(...))` | ✓ WIRED | Datei existiert, Import-Pfad korrekt |
| `TenderSchedulerService` Cron-Tick | `TenderIngestionService.pollDueSources()` | `.catch(logErr)` im CronJob-Callback | ✓ WIRED | Code-Inspektion bestaetigt |
| `prisma.tender.upsert({where:{dedupKey}})` | SCHEMA-02 Change-Detection | Upsert-Semantik | ✓ WIRED | Test beweist Update-statt-Duplikat |
| `PUT /source-config` | `TenderSchedulerService.setInterval()`/`stopJob()` | direkter Aufruf im Controller | ✓ WIRED | Controller-Test beweist beide Zweige |
| `SourceConfigForm` | `GET/PUT /modules/tender-radar/source-config` | `fetchSourceConfig`/`saveSourceConfig` | ✓ WIRED (Component-Test-Ebene) | Live-Roundtrip nicht beobachtet — siehe Human-Item 2 |
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|---------------------|--------|
| `TendersController.listTenders()` | `items`/`total` | `prisma.tender.findMany`/`count` (echte DB-Query, kein statischer Return) | Ja (Query-basiert) | ✓ FLOWING |
| `SourceConfigForm` | `pollIntervalMin`/`isActive` | `fetchSourceConfig()` -> `GET /source-config` (echte Prisma-Query im Controller) | Ja (kein Hardcoded-Leerwert im Call-Pfad) | ✓ FLOWING (mock-getestet, Live-Pfad siehe Human-Item 2) |
| `tender-radar/page.tsx` | — | keine (statischer Platzhaltertext) | N/A | Bewusster, dokumentierter MVP-Stub fuer Phase 11 (kein verdeckter Stub — im Plan/Summary explizit als solcher deklariert) |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| API-Testsuite vollstaendig gruen | `pnpm --filter @tessera/api test` (DATABASE_URL gesetzt) | 9 Dateien, 74/74 Tests | ✓ PASS |
| Web-Testsuite vollstaendig gruen | `pnpm --filter @tessera/web test` | 20 Dateien, 111/111 Tests | ✓ PASS |
| API `tsc --noEmit` sauber | `cd apps/api && pnpm exec tsc --noEmit -p tsconfig.json` | Exit 0, keine Ausgabe | ✓ PASS |
| Migration live angewendet | `prisma migrate status` | "Database schema is up to date!" (12 Migrationen) | ✓ PASS |
| Tender-Tabelle hat keine tenantId-Spalte (Live-DB) | `information_schema.columns` Query via Prisma | 21 Spalten, `tenantId` nicht enthalten | ✓ PASS |
| Zwei-Mandanten-Test treibt echten Aktivierungspfad | Quellcode-Review von `activateForTenant()` | Beruehrt nur `module`/`tenantModuleActivation`, nichts DÖE-Spezifisches | ✓ PASS |
| Zip-Bomb-Ceiling-Test | Teil der Adapter-Suite | 374ms, gruen | ✓ PASS |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|--------------|------------|-------------|--------|----------|
| CONFIG-01 | 10-02 | Modul im Marketplace registriert und pro Mandant aktivierbar | ✓ SATISFIED (Code) | Seed + Whitelist + generische Marketplace-Seite; Live-Klick-Pfad -> Human-Item 1 |
| INGEST-01 | 10-03 | DÖE OpenData-API zeitgesteuert abgerufen (eForms/OCDS, auth-frei) | ✓ SATISFIED | Adapter mit nativem fetch, echte Fixtures, D-02-Filter bewiesen |
| INGEST-06 | 10-04, 10-05, 10-06 | Poll-once-fan-out-many, Intervall admin-konfigurierbar (inkl. UI) | ✓ SATISFIED | Scheduler + Controller + Web-Formular alle vorhanden und verdrahtet; Live-Roundtrip -> Human-Item 2 |
| SCHEMA-01 | 10-01, 10-03 | Einheitliches, OCDS-orientiertes, plattform-globales Schema | ✓ SATISFIED | Schema-Introspektion bestaetigt globale Tabelle + alle Felder |
| SCHEMA-02 | 10-04 | Content-Hash-Change-Detection statt Duplikat | ✓ SATISFIED | Upsert-Test beweist Update-in-Place |
Keine verwaisten (orphaned) Requirements gefunden — alle 5 in REQUIREMENTS.md gelisteten IDs fuer Phase 10 sind in mindestens einem Plan deklariert und durch Coverage-Eintraege belegt.
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| — | — | Keine TBD/FIXME/XXX/TODO/HACK/PLACEHOLDER-Marker in `apps/api/src/tenders/**` oder den Tender-Radar-Web-Dateien gefunden | — | — |
| `apps/web/.../tender-radar/page.tsx` | ganze Datei | Hartcodierter deutscher Platzhaltertext, keine echten Daten | ℹ️ Info | Explizit im Plan 10-02 als MVP-Stub deklariert, mit Doc-Kommentar "replace this whole component in Phase 11" — kein verdeckter Stub, akzeptiert |
| Live-Docker-Container `tessera-ctl-api-1` | Boot-Logs | Container-Image ist AELTER als Phase 10 — keine `TendersController`/`TendersModule`-Routen im Boot-Log, `TenderSourcePollConfig`-Tabelle in der Live-DB leer (0 Zeilen) trotz garantiertem Boot-Seed im Code | ⚠️ Warning | Kein Code-Defekt (Migration + Code sind korrekt und durch Tests bewiesen); aber es bedeutet, dass niemand — weder Executor noch Verifier — den Boot-Seed oder einen Live-HTTP-Request gegen die neuen Routen tatsaechlich beobachtet hat. Rebuild liegt laut Projekt-Konvention beim User |
### Human Verification Required
Siehe YAML-Frontmatter `human_verification` fuer die drei Items (Marketplace-Aktivierung + Seiten-Load, Settings-Formular-Roundtrip, Boot-Seed-Bestaetigung nach Rebuild). Alle drei sind bereits vom Executor selbst als offene UAT-Punkte dokumentiert (10-02 SUMMARY D2/D3, 10-06 SUMMARY D3) und werden hier bestaetigt, nicht neu erfunden.
### Gaps Summary
Keine Gaps auf Code-Ebene gefunden. Alle 5 ROADMAP-Erfolgskriterien sind durch eigene Nachpruefung (Live-Schema-Introspektion, Quellcode-Review, tatsaechliches Ausfuehren beider Testsuiten, Pruefung des echten `activateForTenant()`-Pfads) bestaetigt — nicht nur durch SUMMARY-Behauptungen uebernommen. Der Status ist `human_needed` statt `passed` ausschliesslich wegen des veralteten laufenden Docker-Images, das eine echte Browser-Bestaetigung des End-to-End-Flows verhindert. Dies ist keine Code-Luecke, sondern eine Infrastruktur-/Deployment-Luecke, die dem Projekt-Konvention entsprechend beim User liegt (Rebuild). Empfehlung: kurzer manueller UAT-Durchlauf nach Stack-Rebuild, dann kann Phase 11 starten.
---
_Verified: 2026-07-21T09:32:05Z_
_Verifier: Claude (gsd-verifier)_