166 lines
11 KiB
Markdown
166 lines
11 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
@$HOME/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: SourceConfigDto + TenderQueryDto</name>
|
|
<read_first>
|
|
- 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)
|
|
</read_first>
|
|
<files>apps/api/src/tenders/dto/source-config.dto.ts, apps/api/src/tenders/dto/tender-query.dto.ts</files>
|
|
<action>
|
|
`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-*).
|
|
</action>
|
|
<verify>
|
|
<automated>cd apps/api && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | tail -5</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- Both DTOs compile with class-validator decorators; numeric bounds present.
|
|
- `SourceConfigDto.pollIntervalMin` has `@Min(5)`.
|
|
</acceptance_criteria>
|
|
<done>DTOs defined following the DKV validation conventions.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: TendersController — global read (ModuleGuard) + admin source-config (Roles) + live scheduler apply</name>
|
|
<read_first>
|
|
- 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)
|
|
</read_first>
|
|
<files>apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.module.ts</files>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>cd apps/api && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | tail -5 && grep -c "UseModule" src/tenders/tenders.controller.ts</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `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.
|
|
</acceptance_criteria>
|
|
<done>Global read + admin config wired; scheduler updates live on save.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 3: Controller test — global read not tenant-scoped + admin config applies to scheduler</name>
|
|
<read_first>
|
|
- apps/api/src/cert-manager/cert-manager.service.spec.ts (vitest spec style)
|
|
- apps/api/src/tenders/tenders.controller.ts (unit under test)
|
|
</read_first>
|
|
<behavior>
|
|
- 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().
|
|
</behavior>
|
|
<files>apps/api/src/tenders/tenders.controller.spec.ts</files>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>cd apps/api && pnpm test -- tenders.controller 2>&1 | tail -15</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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.
|
|
</acceptance_criteria>
|
|
<done>Controller behaviour has automated coverage; green.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
- `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.
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
Create `.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-05-SUMMARY.md` when done.
|
|
</output>
|