feat(10-05): wire TendersController — global read + admin source-config

- GET / and GET /🆔 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
This commit is contained in:
2026-07-21 11:17:13 +02:00
parent f6e636c37d
commit 7b9b6b8c1c
2 changed files with 142 additions and 4 deletions
+135
View File
@@ -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;
}
}
+7 -4
View File
@@ -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,