Files
tessera-ctl/.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-05-PLAN.md
T

11 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
10-ausschreibungs-radar-foundation-d-e-ingestion 05 execute 5
10-01
10-02
10-03
10-04
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
true
INGEST-06
truths artifacts key_links
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
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
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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.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.

<threat_model>

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
</threat_model>
- `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.

<success_criteria>

  • INGEST-06: admin-configurable shared poll interval, applied to the live scheduler without restart, plus a global read surface for the ingested catalog. </success_criteria>
Create `.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-05-SUMMARY.md` when done.