---
phase: 10-ausschreibungs-radar-foundation-d-e-ingestion
plan: 05
type: execute
wave: 5
depends_on: ["10-01", "10-02", "10-03", "10-04"]
files_modified:
- 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
- apps/api/src/tenders/tenders.module.ts
autonomous: true
requirements: [INGEST-06]
must_haves:
truths:
- "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 admin-configurable)"
- "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"
- "Admin source-config routes require ADMIN/SUPER_ADMIN roles"
artifacts:
- apps/api/src/tenders/tenders.controller.ts
- apps/api/src/tenders/dto/source-config.dto.ts
- apps/api/src/tenders/dto/tender-query.dto.ts
key_links:
- "PUT source-config → TenderSchedulerService.setInterval()/stopJob() applies the change live (DKV saveConfig pattern, minus the tenantId arg)"
- "GET /tenders uses @UseModule('tender-radar') ModuleGuard, never a where:{tenantId} filter"
---
Round out INGEST-06: expose the admin-configurable shared poll interval and a read endpoint over the global tender catalog. This is the boundary where "tenant-gated" and "tenant-scoped" genuinely differ — the read must be gated by module activation but NOT row-filtered by tenant.
## Phase Goal (user story)
**As a** Tessera-Administrator, **I want to** das DÖE-Poll-Intervall im Admin-Bereich einstellen und die erfassten Ausschreibungen ueber eine API abrufen, **so that** ich den gemeinsamen Zeitplan steuern kann und Phase 11 eine Trefferliste darauf aufbauen kann (INGEST-06).
Purpose: Completes the admin-configurable half of INGEST-06 and gives Phase 11 a read surface. The controller intentionally diverges from DKV: list/detail are platform-global reads gated only by module licensing.
Output: `TendersController`, `SourceConfigDto`, `TenderQueryDto`, controller test.
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-PATTERNS.md
@.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-RESEARCH.md
@apps/api/src/dkv/dkv.controller.ts
@apps/api/src/cert-manager/cert-manager.controller.ts
@apps/api/src/dkv/dto/dkv-config.dto.ts
@apps/api/src/dkv/dto/dkv-history.dto.ts
Task 1: SourceConfigDto + TenderQueryDto
- apps/api/src/dkv/dto/dkv-config.dto.ts (class-validator conventions: @IsOptional + @Min/@Max, DoS floor)
- apps/api/src/dkv/dto/dkv-history.dto.ts (pagination DTO: page/limit + @Type(() => Number))
- .planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-PATTERNS.md (dto sections)
apps/api/src/tenders/dto/source-config.dto.ts, apps/api/src/tenders/dto/tender-query.dto.ts
`SourceConfigDto`: `pollIntervalMin?` (`@IsOptional @IsInt @Min(5) @Max(1440)` — same DoS floor as DkvConfigDto) and `isActive?` (`@IsOptional @IsBoolean`). Document in a comment that `pollIntervalMin` is the cron-tick frequency (D-04 default 60), decoupled from the day-granularity DÖE fetch (day-cursor gated in the ingestion service).
`TenderQueryDto`: pagination `page?`/`limit?` copied verbatim from `dkv-history.dto.ts` (`@Type(() => Number)` coercion, `@Min(1)`, `limit @Max(100)`), plus an optional `status?` filter (`@IsOptional @IsIn(['active','expired'])`) defaulting to active-only at the query layer. No region/CPV filters here — rich filtering is Phase 11 (FILTER-*).
cd apps/api && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | tail -5
- Both DTOs compile with class-validator decorators; numeric bounds present.
- `SourceConfigDto.pollIntervalMin` has `@Min(5)`.
DTOs defined following the DKV validation conventions.
Task 2: TendersController — global read (ModuleGuard) + admin source-config (Roles) + live scheduler apply
- apps/api/src/cert-manager/cert-manager.controller.ts (@UseModule('...') ModuleGuard usage — the module-activation gate for global reads)
- apps/api/src/dkv/dkv.controller.ts (per-handler @Roles(ADMIN, SUPER_ADMIN) on config routes; saveConfig → scheduler.setInterval/stopJob pattern, lines ~76-90)
- .planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-PATTERNS.md (tenders.controller section — divergence from DKV)
apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.module.ts
Create `TendersController` (`@Controller('modules/tender-radar')`). Read routes are GLOBAL (V4 divergence): `GET /` (list, paginated via `TenderQueryDto`) and `GET /:id` (detail) query the global `Tender` table via plain `PrismaService` with `where` built ONLY from the query DTO (status/pagination) — never a `where: { tenantId }` filter. Gate these reads with `@UseModule('tender-radar')` (ModuleGuard) so only tenants with the module active can read, without row-scoping the global catalog (RESEARCH V4: this is the one controller where tenant-gated differs from tenant-scoped). Return 404 via `NotFoundException` for a missing id.
Admin source-config routes: `GET /source-config` and `PUT /source-config`, each decorated `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` (per-handler, like DkvController). `GET` returns the singleton `doe-opendata` config. `PUT` (body `SourceConfigDto`) upserts the singleton config, then applies it live to the scheduler: if `isActive && pollIntervalMin` then call `tenderScheduler.setInterval(pollIntervalMin)` (NO tenant arg — divergence from DKV); if `isActive === false` then call `tenderScheduler.stopJob()`. This satisfies INGEST-06 "admin-configurable interval, applied without restart".
Register `TendersController` in `TendersModule.controllers` and confirm `TenderSchedulerService`/`TenderIngestionService` are in providers so DI resolves.
cd apps/api && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | tail -5 && grep -c "UseModule" src/tenders/tenders.controller.ts
- `GET /` and `GET /:id` gated by `@UseModule('tender-radar')`; no `where: { tenantId }` in the controller (assert `grep -c "tenantId" tenders.controller.ts` returns 0 for read routes — the global catalog is not row-scoped).
- Both `source-config` routes carry `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`.
- `PUT /source-config` calls `tenderScheduler.setInterval`/`stopJob` (no tenant arg).
- `tsc --noEmit` passes.
Global read + admin config wired; scheduler updates live on save.
Task 3: Controller test — global read not tenant-scoped + admin config applies to scheduler
- apps/api/src/cert-manager/cert-manager.service.spec.ts (vitest spec style)
- apps/api/src/tenders/tenders.controller.ts (unit under test)
- Test: GET list returns global tenders with no tenantId filter passed to prisma (assert the prisma.tender.findMany where has no tenantId key).
- Test: PUT /source-config with isActive=true + pollIntervalMin=30 calls scheduler.setInterval(30) with a single argument.
- Test: PUT /source-config with isActive=false calls scheduler.stopJob().
apps/api/src/tenders/tenders.controller.spec.ts
Write a Vitest spec mocking `PrismaService` and `TenderSchedulerService`. Assert the read path never adds a `tenantId` to the prisma `where`, and that saving source-config drives `setInterval(intervalMin)` / `stopJob()` exactly as the DKV analog does (minus the tenant argument). This is the automated proof for the INGEST-06 admin-config surface and the tenant-gated-not-scoped invariant.
cd apps/api && pnpm test -- tenders.controller 2>&1 | tail -15
- Read-path test asserts no `tenantId` in the prisma `where`.
- Config-save tests assert `setInterval(n)` (single arg) and `stopJob()` are invoked.
- `pnpm --filter @tessera/api test -- tenders.controller` green.
Controller behaviour has automated coverage; green.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → GET /tenders | Any authenticated tenant user with the module active reads the global catalog |
| admin → PUT /source-config | Privileged mutation of the platform-wide poll schedule |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-10-13 | Elevation of Privilege | `PUT /source-config` | high | mitigate | Both source-config routes require `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` (per-handler, like DkvController); global JwtAuthGuard + RolesGuard enforce it |
| T-10-14 | Broken Access Control | `GET /tenders` gating | high | mitigate | Reads gated by `@UseModule('tender-radar')` ModuleGuard (tenant must have the module active) — deliberately NOT row-scoped by tenantId, since the catalog is platform-global (V4: tenant-gated ≠ tenant-scoped). Verified by the no-tenantId controller test |
| T-10-15 | Input Validation | `TenderQueryDto` / `SourceConfigDto` | medium | mitigate | class-validator bounds on pagination (`limit @Max(100)`) and interval (`@Min(5) @Max(1440)`) mitigate DoS via oversized queries / runaway poll frequency |
- `pnpm --filter @tessera/api test -- tenders.controller` green.
- GET reads gated by ModuleGuard, not tenantId-filtered (grep + test).
- Admin source-config Roles-guarded; save applies to the live scheduler.
- `tsc --noEmit` clean for both apps.
- INGEST-06: admin-configurable shared poll interval, applied to the live scheduler without restart, plus a global read surface for the ingested catalog.