From 7b9b6b8c1cd99bb267addf8289e75344e7f1d43f Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 11:17:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(10-05):=20wire=20TendersController=20?= =?UTF-8?q?=E2=80=94=20global=20read=20+=20admin=20source-config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET / and GET /:id: paginated global Tender catalog, @UseModule('tender-radar')-gated, never row-scoped by tenant id - GET/PUT /source-config: @Roles(ADMIN, SUPER_ADMIN)-guarded singleton doe-opendata config - PUT /source-config live-applies pollIntervalMin/isActive to TenderSchedulerService (setInterval/stopJob, no tenant arg) — INGEST-06 - Registered TendersController in TendersModule.controllers --- apps/api/src/tenders/tenders.controller.ts | 135 +++++++++++++++++++++ apps/api/src/tenders/tenders.module.ts | 11 +- 2 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/tenders/tenders.controller.ts diff --git a/apps/api/src/tenders/tenders.controller.ts b/apps/api/src/tenders/tenders.controller.ts new file mode 100644 index 0000000..b700a91 --- /dev/null +++ b/apps/api/src/tenders/tenders.controller.ts @@ -0,0 +1,135 @@ +import { + Body, + Controller, + Get, + NotFoundException, + Param, + Put, + Query, +} from '@nestjs/common'; +import { Role } from '@prisma/client'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UseModule } from '../module-registry/module.guard'; +import { PrismaService } from '../prisma/prisma.service'; +import { SourceConfigDto } from './dto/source-config.dto'; +import { TenderQueryDto } from './dto/tender-query.dto'; +import { TenderSchedulerService } from './tender-scheduler.service'; + +const DOE_SOURCE_TYPE = 'doe-opendata'; + +/** + * TendersController — `/modules/tender-radar/*` routes. + * + * Deliberate structural divergence from DkvController (RESEARCH.md V4, + * PATTERNS.md): `GET /` and `GET /:id` read the GLOBAL `Tender` catalog, + * gated only by `@UseModule('tender-radar')` (module activation) — NEVER + * row-scoped by the tenant's id. The tender catalog is platform-wide data + * (D-03); "tenant-gated" (can this tenant see the feature at all) and + * "tenant-scoped" (filter rows by tenant) are genuinely different things + * here, unlike every other module in this codebase. + * + * Admin source-config routes (`GET`/`PUT /source-config`) are, like + * DkvController, per-handler `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`-guarded + * (T-10-13) and are NOT gated by @UseModule — an admin configuring the + * shared platform-wide poll schedule is a platform-admin action, not a + * per-tenant module feature. + */ +@Controller('modules/tender-radar') +export class TendersController { + constructor( + private readonly prisma: PrismaService, + private readonly tenderScheduler: TenderSchedulerService, + ) {} + + // ─── Global read (ModuleGuard-gated, NOT tenant-scoped) ──────────────────── + + /** + * GET /modules/tender-radar — paginated global tender catalog. + * Gated by @UseModule('tender-radar'): only tenants with the module + * active can read. Deliberately NOT filtered by the tenant's id — the + * catalog is platform-global (D-03). + */ + @Get() + @UseModule('tender-radar') + async listTenders(@Query() query: TenderQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const status = query.status ?? 'active'; + const skip = (page - 1) * limit; + + const [items, total] = await Promise.all([ + this.prisma.tender.findMany({ + where: { status }, + orderBy: { publishedAt: 'desc' }, + skip, + take: limit, + }), + this.prisma.tender.count({ where: { status } }), + ]); + + return { items, total, page, limit }; + } + + /** + * GET /modules/tender-radar/:id — single tender detail. + * Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id + * (global row). + */ + @Get(':id') + @UseModule('tender-radar') + async getTender(@Param('id') id: string) { + const tender = await this.prisma.tender.findUnique({ where: { id } }); + if (!tender) { + throw new NotFoundException('Tender not found'); + } + return tender; + } + + // ─── 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; + } + + /** + * PUT /modules/tender-radar/source-config — upsert the singleton + * doe-opendata poll config, then apply the change live to the scheduler + * (INGEST-06: admin-configurable interval, no restart required). + * + * Divergence from DkvController.saveConfig: no tenant-id argument to + * setInterval()/stopJob() — this config is a platform-wide singleton. + */ + @Put('source-config') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async saveSourceConfig(@Body() dto: SourceConfigDto) { + const result = await this.prisma.tenderSourcePollConfig.upsert({ + where: { sourceType: DOE_SOURCE_TYPE }, + update: { ...dto }, + create: { + sourceType: DOE_SOURCE_TYPE, + pollIntervalMin: dto.pollIntervalMin ?? 60, + isActive: dto.isActive ?? false, + }, + }); + + if (dto.isActive && dto.pollIntervalMin) { + this.tenderScheduler.setInterval(dto.pollIntervalMin); + } else if (dto.isActive === false) { + this.tenderScheduler.stopJob(); + } + + return result; + } +} diff --git a/apps/api/src/tenders/tenders.module.ts b/apps/api/src/tenders/tenders.module.ts index d856143..10b2422 100644 --- a/apps/api/src/tenders/tenders.module.ts +++ b/apps/api/src/tenders/tenders.module.ts @@ -7,6 +7,7 @@ import { seedTendersModule } from './tenders.seed'; import { TenderIngestionService } from './tender-ingestion.service'; import { TenderNormalizerService } from './tender-normalizer.service'; import { TenderSchedulerService } from './tender-scheduler.service'; +import { TendersController } from './tenders.controller'; /** * NestJS module for the Ausschreibungs-Radar feature. @@ -14,10 +15,12 @@ import { TenderSchedulerService } from './tender-scheduler.service'; * Wave 2 registered the module in the marketplace and seeded the * singleton DÖE poll config so the shared poll is admin-drivable. Plan 03 * added the DÖE source adapter + normalizer (parse+map core of - * INGEST-01/SCHEMA-01). This plan (04) adds ingestion orchestration + * INGEST-01/SCHEMA-01). Plan 04 added ingestion orchestration * (TenderIngestionService: day-cursor gate, SCHEMA-02 change detection, - * D-05 retention) and the shared global scheduler. Controller/DTO wiring - * is added in Plan 05. + * D-05 retention) and the shared global scheduler. This plan (05) wires + * TendersController: the global (ModuleGuard-gated, not tenant-scoped) + * read surface plus the Roles-guarded admin source-config routes that + * live-apply to TenderSchedulerService. * * PrismaModule is global (no explicit import needed). * @@ -26,7 +29,7 @@ import { TenderSchedulerService } from './tender-scheduler.service'; */ @Module({ imports: [ModuleRegistryModule], - controllers: [], + controllers: [TendersController], providers: [ DoeOpenDataAdapter, TenderNormalizerService,