--- phase: 10-ausschreibungs-radar-foundation-d-e-ingestion plan: 05 subsystem: api tags: [controller, dto, module-guard, roles-guard, scheduler-live-apply, tdd, tenant-gated-not-scoped] # Dependency graph requires: - phase: 10-01 (foundation & dependencies) provides: Tender / TenderSourcePollConfig Prisma models (dedupKey @unique, sourceType @unique) - phase: 10-02 (marketplace registration) provides: TendersModule skeleton, singleton doe-opendata poll config seeded on boot - phase: 10-03 (adapter & normalizer) provides: DoeOpenDataAdapter, TenderNormalizerService - phase: 10-04 (ingestion orchestration & scheduler) provides: TenderSchedulerService.setInterval()/stopJob() (no tenant arg), TenderIngestionService.pollDueSources() provides: - "TendersController — GET/GET:id (global read, @UseModule-gated, never row-scoped by tenant id) + GET/PUT source-config (Roles-guarded singleton config, live-applies to the scheduler)" - "SourceConfigDto, TenderQueryDto — class-validator DTOs following the DKV conventions" - "Automated proof (controller spec) of the tenant-gated-not-scoped invariant and the live scheduler-apply on config save" affects: [phase 11 saved searches/filter UI (consumes GET / and GET /:id as the read surface over the ingested catalog)] # Tech tracking tech-stack: added: [] patterns: - "Global-read-gated-by-module-not-by-tenant-row: GET / and GET /:id use plain PrismaService with a where clause built only from query params (status/pagination), gated exclusively by @UseModule('tender-radar') — this is the one controller in the codebase where tenant-gated (feature visibility) and tenant-scoped (row filtering) genuinely diverge" - "Admin platform-config routes are Roles-guarded, not ModuleGuard-gated — configuring the shared platform-wide poll schedule is a platform-admin action, not a per-tenant module feature, so @UseModule is deliberately absent from source-config routes" - "Scheduler live-apply on config save: PUT /source-config calls tenderScheduler.setInterval(intervalMin) / stopJob() with no tenant argument, mirroring DkvController.saveConfig minus the per-tenant parameter (the DÖE config is a platform-wide singleton)" key-files: created: - apps/api/src/tenders/dto/source-config.dto.ts - apps/api/src/tenders/dto/tender-query.dto.ts - apps/api/src/tenders/tenders.controller.ts - apps/api/src/tenders/tenders.controller.spec.ts modified: - apps/api/src/tenders/tenders.module.ts key-decisions: - "Controller talks to PrismaService directly (no intermediate TenderService/TenderConfigService layer) — the plan's files_modified list and PATTERNS.md controller sketch show no such service; source-config upsert and the global read query are simple enough that adding a service layer would be premature indirection for this plan's scope" - "Comment wording avoids the literal 'tenantId' token in tenders.controller.ts (uses 'the tenant's id' / 'tenant-id' instead) — the acceptance criterion's grep-gate (`grep -c tenantId tenders.controller.ts` must return 0) checks for the literal string; an explanatory comment naming the concept using that exact identifier would trip the same false-positive class of issue Plan 10-04 documented and fixed twice already in this phase" - "TenderQueryDto.status defaults to 'active' at the controller layer (query DTO field itself has no default value, per plan spec — defaulting happens in listTenders(), matching DkvController's page/limit default-at-usage-site pattern)" requirements-completed: [INGEST-06] coverage: - id: D1 description: "Admin can read and update the shared DÖE poll interval / active state; saving pushes the change into the live scheduler without restart (INGEST-06)" requirement: "INGEST-06" verification: - kind: unit ref: "tenders.controller.spec.ts — 'PUT /source-config with isActive=true + pollIntervalMin=30 calls scheduler.setInterval(30) with a single argument'; 'PUT /source-config with isActive=false calls scheduler.stopJob()'" status: pass - kind: automated_ui ref: "grep -n '@Roles' tenders.controller.ts — both source-config handlers carry @Roles(Role.ADMIN, Role.SUPER_ADMIN)" status: pass human_judgment: false - id: D2 description: "GET /tenders list + detail is gated by module activation (ModuleGuard), NOT by tenantId row-filtering — the global catalog is visible to any tenant with the module active" requirement: "INGEST-06" verification: - kind: unit ref: "tenders.controller.spec.ts — 'GET / calls prisma.tender.findMany with a where clause that has no tenantId key'" status: pass - kind: automated_ui ref: "grep -c UseModule tenders.controller.ts == 7 (decorator + doc mentions); grep -c tenantId tenders.controller.ts == 0" status: pass human_judgment: false - id: D3 description: "Admin source-config routes require ADMIN/SUPER_ADMIN roles" requirement: "INGEST-06" verification: - kind: automated_ui ref: "grep -n '@Roles' tenders.controller.ts — 2 matches, both on GET/PUT source-config handlers" status: pass human_judgment: false # Metrics duration: ~20min completed: 2026-07-21 status: complete --- # Phase 10 Plan 05: Tenders Controller — Global Read + Admin Source-Config Summary **`TendersController` wires the phase's one deliberately-divergent read surface — `GET /` and `GET /:id` gated by `@UseModule('tender-radar')` but never row-scoped by tenant — alongside `@Roles`-guarded `GET`/`PUT /source-config` that live-applies interval/active-state changes to `TenderSchedulerService`, completing INGEST-06's admin-configurable half.** ## Performance - **Duration:** ~20 min - **Started:** 2026-07-21 - **Completed:** 2026-07-21 - **Tasks:** 3 (Task 1 auto, Task 2 auto, Task 3 tdd="true" — spec passed immediately against Task 2's already-correct implementation) - **Files modified:** 5 (4 created, 1 modified) ## Accomplishments - `SourceConfigDto` (`pollIntervalMin` `@Min(5) @Max(1440)`, `isActive` `@IsBoolean`) and `TenderQueryDto` (`page`/`limit` pagination copied from `DkvHistoryQueryDto`, plus `status` `@IsIn(['active','expired'])`) — both class-validator DTOs following the exact DKV conventions (DoS-mitigation bounds, `@Type(() => Number)` query-string coercion). - `TendersController` (`@Controller('modules/tender-radar')`): - `GET /` and `GET /:id` — paginated global `Tender` catalog read via plain `PrismaService`, gated only by `@UseModule('tender-radar')` (module activation), never row-scoped by a `where: { tenantId }` filter. `GET /:id` throws `NotFoundException` for a missing id. - `GET /source-config` / `PUT /source-config` — both `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`-guarded. `PUT` upserts the singleton `doe-opendata` config, then live-applies the change to `TenderSchedulerService.setInterval(intervalMin)` (single argument, no tenant id) when active, or `stopJob()` when deactivated — satisfying INGEST-06's "applied without restart" requirement. - `TendersController` registered in `TendersModule.controllers`; `TenderSchedulerService` (already a provider since Plan 04) resolves via DI. - Controller spec (5 tests): proves the read path never adds a `tenantId` key to the prisma `where`, `GET /:id` 404s for a missing tender, and both config-save branches (`isActive=true`+interval / `isActive=false`) drive the scheduler exactly as specified. - Full API test suite green (74/74), `tsc --noEmit` clean for both `apps/api` and `apps/web`, all grep gates (`UseModule` present, `tenantId` absent, `@Roles` on both admin routes) pass. ## Task Commits Each task committed atomically: 1. **Task 1: SourceConfigDto + TenderQueryDto** — `f6e636c` (feat) 2. **Task 2: TendersController — global read (ModuleGuard) + admin source-config (Roles) + live scheduler apply** — `7b9b6b8` (feat) 3. **Task 3: Controller test — global read not tenant-scoped + admin config applies to scheduler** — `eaff1d8` (test) **Plan metadata:** see final `docs(10-05)` commit. ## TDD Gate Compliance - Task 3 (`tdd="true"`): the spec (`eaff1d8`, `test(10-05): ...`) passed on first run, immediately after being written — **this is expected, not an unexpected-pass violation of the RED-GREEN gate.** Task 2's `feat` commit (`7b9b6b8`) already implemented the tenant-gated-not-scoped read path and the scheduler live-apply correctly; Task 3's test locks in and regression-proofs that already-correct behavior via genuine assertions on the prisma `where` clause and scheduler mock calls, rather than driving new production code. This plan's frontmatter is `type: execute` with a per-task `tdd="true"` flag (not a full `type: tdd` plan), matching the exact precedent documented in Plan 10-04's Task 3 — the strict "investigate a passing RED" rule applies to full-plan TDD gates, not this per-task flag usage. - No REFACTOR commit was needed — the spec passed cleanly on first write, no post-green cleanup required. ## Files Created/Modified - `apps/api/src/tenders/dto/source-config.dto.ts` — `pollIntervalMin` (`@IsOptional @IsInt @Min(5) @Max(1440)`), `isActive` (`@IsOptional @IsBoolean`) - `apps/api/src/tenders/dto/tender-query.dto.ts` — `page`/`limit` pagination (`@Type(() => Number)` coercion, `@Min(1)`, `limit @Max(100)`) + `status` (`@IsOptional @IsIn(['active','expired'])`) - `apps/api/src/tenders/tenders.controller.ts` — `TendersController`: `GET /`, `GET /:id` (global, `@UseModule`-gated), `GET /source-config`, `PUT /source-config` (`@Roles`-guarded, scheduler live-apply) - `apps/api/src/tenders/tenders.controller.spec.ts` — 5 tests: no-tenantId read-path assertion, detail found/404, `setInterval(30)` single-arg assertion, `stopJob()` assertion - `apps/api/src/tenders/tenders.module.ts` — `TendersController` added to `controllers` ## Decisions Made - **No intermediate service layer for the controller** — `TendersController` talks to `PrismaService` and `TenderSchedulerService` directly. The plan's `files_modified` list and PATTERNS.md's controller sketch show no `TenderConfigService`/`TenderQueryService`; the source-config upsert and the paginated global read are simple enough that an intermediate service would be premature indirection for this plan's scope. `TenderIngestionService`/`TenderSchedulerService` remain the sole business-logic services (Plan 04). - **Comment wording avoids the literal `tenantId` token** in `tenders.controller.ts` (rephrased as "the tenant's id" / "tenant-id" throughout) — the acceptance criterion's grep gate (`grep -c tenantId tenders.controller.ts` must return 0) checks for the literal string, and an explanatory comment naming the concept with that exact identifier would trip the same false-positive class of issue Plan 10-04 documented (and auto-fixed twice) in this phase. Caught during Task 2's own verification pass before committing. - **`TenderQueryDto.status` has no DTO-level default** — matching `DkvController`'s existing `page ?? 1` / `limit ?? 20` pattern, the `'active'`-only default is applied at the controller call site (`query.status ?? 'active'`), not baked into the DTO class itself. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] `@Body()` decorator missing on `saveSourceConfig`'s parameter** - **Found during:** Task 2 authoring (caught before running verification) - **Issue:** Initial draft of `saveSourceConfig(dto: SourceConfigDto)` omitted the `@Body()` parameter decorator, which would have left `dto` as `undefined` at runtime (NestJS only populates decorated parameters). - **Fix:** Added `@Body()` and its import from `@nestjs/common`. - **Files modified:** `apps/api/src/tenders/tenders.controller.ts` - **Commit:** folded into `7b9b6b8` (Task 2 commit, caught and fixed before commit) **2. [Rule 1 - Bug] Literal `tenantId` token in explanatory comments tripped the plan's own grep-gate acceptance criterion** - **Found during:** Task 2 verification (`grep -c tenantId tenders.controller.ts` initially returned 4, not the required 0) - **Issue:** Doc comments explaining the tenant-gated-not-scoped divergence used the literal word "tenantId" to describe what the code does NOT do — the same class of false-positive documented twice in Plan 10-04's Deviations (grep-gate assertions check for the literal string, regardless of whether it appears in code or in a comment explaining its absence). - **Fix:** Reworded all four occurrences to "the tenant's id" / "tenant-id" (with an explicit space/hyphen breaking the literal token match) while preserving the exact same explanatory meaning. - **Files modified:** `apps/api/src/tenders/tenders.controller.ts` - **Verification:** `grep -c tenantId tenders.controller.ts` → 0; `tsc --noEmit` clean; full spec suite still green. - **Committed in:** `7b9b6b8` (Task 2 commit, caught and fixed before commit) **3. [Rule 3 - Blocking] `tsc` type errors in the controller spec's mock typing** - **Found during:** Task 3 post-write `tsc --noEmit` verification - **Issue:** `prisma.tender.findMany`/`count` fakes were declared as zero-argument `vi.fn(async () => ...)`, which TypeScript inferred as a `[]`-length call-args tuple — `mock.calls[0][0]` then failed with "Tuple type '[]' of length '0' has no element at index '0'". - **Fix:** Added an explicit optional `_args?: any` parameter to both fakes so `mock.calls[0][0]` type-checks. - **Files modified:** `apps/api/src/tenders/tenders.controller.spec.ts` - **Verification:** `tsc --noEmit` clean; `pnpm test -- tenders.controller` still green (5/5). - **Committed in:** `eaff1d8` (Task 3 commit, caught and fixed before commit) --- **Total deviations:** 3 auto-fixed (2 Bugs, 1 Blocking — all caught and fixed inline before their respective commits, zero scope creep, zero behavior change beyond the fixes themselves) ## Issues Encountered None beyond the three documented deviations above. Local Docker Postgres stack (per environment context) was not needed — the controller spec uses in-memory prisma-shaped fakes, matching this phase's established test convention (10-04's ingestion/scheduler specs), so no live-DB dependency was introduced. ## User Setup Required None — no external service configuration required. The local Docker stack (`tessera-ctl-api-1`/`-db-1`) was left running unmodified; restarting/rebuilding it to pick up these code changes (so the new `/modules/tender-radar` routes are actually reachable over HTTP) is left to the user per project convention. ## Next Phase Readiness - INGEST-06 is now fully complete: the platform-wide poll interval is admin-readable/writable via `GET`/`PUT /modules/tender-radar/source-config`, and saving live-applies to the running scheduler without a restart (Plan 04 built the scheduler mechanics; this plan wires the admin-facing surface to it). - `GET /modules/tender-radar` and `GET /modules/tender-radar/:id` give Phase 11 a ready-made, paginated, `@UseModule`-gated read surface over the ingested global catalog — Phase 11's saved-searches/filter UI can consume these directly and layer richer filtering (region/CPV — FILTER-*) on top without touching the tenant-gated-not-scoped invariant established here. - No open threat-model items from this plan carry forward unmitigated: T-10-13 (Elevation of Privilege on `PUT /source-config`) is mitigated by `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` + the global `RolesGuard`; T-10-14 (Broken Access Control on `GET /tenders`) is mitigated by `@UseModule('tender-radar')` and proven by the no-tenantId controller test; T-10-15 (Input Validation DoS) is mitigated by `SourceConfigDto`/`TenderQueryDto`'s class-validator bounds. - No blockers for the next phase (11, saved searches/filter UI over this ingested catalog). ## Self-Check: PASSED - FOUND: apps/api/src/tenders/dto/source-config.dto.ts - FOUND: apps/api/src/tenders/dto/tender-query.dto.ts - FOUND: apps/api/src/tenders/tenders.controller.ts - FOUND: apps/api/src/tenders/tenders.controller.spec.ts - FOUND: apps/api/src/tenders/tenders.module.ts - FOUND commit: f6e636c - FOUND commit: 7b9b6b8 - FOUND commit: eaff1d8 --- *Phase: 10-ausschreibungs-radar-foundation-d-e-ingestion* *Completed: 2026-07-21*