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;
}
}