From 48d1246043d006b0a2e188069d9487fdadcbf435 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 14:52:17 +0200 Subject: [PATCH] fix(10): resolve GET /source-config 404 shadowed by :id route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .planning/STATE.md | 14 +++---- .../10-VERIFICATION.md | 28 ++++++++++++-- .../src/tenders/tenders.controller.spec.ts | 19 ++++++++++ apps/api/src/tenders/tenders.controller.ts | 38 +++++++++++-------- 4 files changed, 73 insertions(+), 26 deletions(-) diff --git a/.planning/STATE.md b/.planning/STATE.md index 645c33d..4835efe 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,17 +4,17 @@ milestone: v1.1 milestone_name: Ausschreibungs-Radar current_phase: 10 current_phase_name: ausschreibungs-radar-foundation-d-e-ingestion -status: verifying -stopped_at: Completed 10-06-PLAN.md -last_updated: "2026-07-21T09:27:21.391Z" +status: verified +stopped_at: Phase 10 verified (live UAT passed; route-order bug fixed) +last_updated: "2026-07-21T12:44:00Z" last_activity: 2026-07-21 -last_activity_desc: Phase 10 execution started +last_activity_desc: Phase 10 live browser UAT passed; GET /source-config route-order bug fixed + regression test progress: total_phases: 14 - completed_phases: 9 + completed_phases: 10 total_plans: 46 - completed_plans: 45 - percent: 64 + completed_plans: 46 + percent: 71 --- # Project State diff --git a/.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-VERIFICATION.md b/.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-VERIFICATION.md index 1d02edf..9594526 100644 --- a/.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-VERIFICATION.md +++ b/.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-VERIFICATION.md @@ -1,11 +1,11 @@ --- phase: 10-ausschreibungs-radar-foundation-d-e-ingestion -verified: 2026-07-21T09:32:05Z -status: human_needed +verified: 2026-07-21T12:44:00Z +status: passed score: 5/5 must-haves verified behavior_unverified: 0 overrides_applied: 0 -re_verification: null +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." @@ -21,6 +21,28 @@ human_verification: # 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 diff --git a/apps/api/src/tenders/tenders.controller.spec.ts b/apps/api/src/tenders/tenders.controller.spec.ts index 7cbd66c..4b89eea 100644 --- a/apps/api/src/tenders/tenders.controller.spec.ts +++ b/apps/api/src/tenders/tenders.controller.spec.ts @@ -105,3 +105,22 @@ describe('TendersController — admin source-config applies live to the schedule expect(scheduler.setInterval).not.toHaveBeenCalled(); }); }); + +describe('TendersController — route declaration order (static route before :id)', () => { + // Regression guard for the GET /source-config → 404 bug: NestJS RouterExplorer + // maps routes in method-declaration order. When `@Get(':id')` is declared + // before `@Get('source-config')`, the param route captures "source-config" as + // an id and shadows the static handler — 401 unauthenticated, 404 once past the + // guard (Tender "source-config" not found). Unit-calling the methods directly + // (the tests above) bypasses routing and cannot catch this, so we assert the + // declaration order explicitly. + it('declares getSourceConfig before getTender so GET /:id cannot shadow it', () => { + const methods = Object.getOwnPropertyNames(TendersController.prototype); + const sourceConfigIdx = methods.indexOf('getSourceConfig'); + const idIdx = methods.indexOf('getTender'); + + expect(sourceConfigIdx).toBeGreaterThanOrEqual(0); + expect(idIdx).toBeGreaterThanOrEqual(0); + expect(sourceConfigIdx).toBeLessThan(idIdx); + }); +}); diff --git a/apps/api/src/tenders/tenders.controller.ts b/apps/api/src/tenders/tenders.controller.ts index b700a91..8f50b26 100644 --- a/apps/api/src/tenders/tenders.controller.ts +++ b/apps/api/src/tenders/tenders.controller.ts @@ -70,6 +70,26 @@ export class TendersController { return { items, total, page, limit }; } + /** + * GET /modules/tender-radar/source-config — read the singleton + * doe-opendata poll config. + * + * MUST be declared before the `:id` route below — NestJS matches routes + * in declaration order, so a `@Get(':id')` placed first would capture + * "source-config" as an id and shadow this handler (404 on GET). + */ + @Get('source-config') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async getSourceConfig() { + const config = await this.prisma.tenderSourcePollConfig.findUnique({ + where: { sourceType: DOE_SOURCE_TYPE }, + }); + if (!config) { + throw new NotFoundException('Tender source config not yet seeded'); + } + return config; + } + /** * GET /modules/tender-radar/:id — single tender detail. * Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id @@ -86,22 +106,8 @@ export class TendersController { } // ─── Admin source-config (Roles-guarded, live scheduler apply) ──────────── - - /** - * GET /modules/tender-radar/source-config — read the singleton - * doe-opendata poll config. - */ - @Get('source-config') - @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async getSourceConfig() { - const config = await this.prisma.tenderSourcePollConfig.findUnique({ - where: { sourceType: DOE_SOURCE_TYPE }, - }); - if (!config) { - throw new NotFoundException('Tender source config not yet seeded'); - } - return config; - } + // NOTE: GET /source-config is declared above the `:id` route (route-order + // matters in NestJS). The PUT below is not shadowed — there is no @Put(':id'). /** * PUT /modules/tender-radar/source-config — upsert the singleton