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>
19 KiB
phase, verified, status, score, behavior_unverified, overrides_applied, re_verification, human_verification
| phase | verified | status | score | behavior_unverified | overrides_applied | re_verification | human_verification | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10-ausschreibungs-radar-foundation-d-e-ingestion | 2026-07-21T12:44:00Z | passed | 5/5 must-haves verified | 0 | 0 | 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. |
|
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.
-
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-radarrenders the lazy-loaded page ("Ausschreibungen werden erfasst." placeholder — real results UI is Phase 11 scope). No 404. -
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')intenders.controller.ts; added a declaration-order regression test intenders.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. -
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").
TenderSourcePollConfigholds exactly 1 row (sourceType='doe-opendata'). Note: on a truly first boot the scheduler logs "config inactive — cron job not registered" because scheduleronModuleInitcan 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)