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:
@@ -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,6 +7,7 @@ import { seedTendersModule } from './tenders.seed';
|
|||||||
import { TenderIngestionService } from './tender-ingestion.service';
|
import { TenderIngestionService } from './tender-ingestion.service';
|
||||||
import { TenderNormalizerService } from './tender-normalizer.service';
|
import { TenderNormalizerService } from './tender-normalizer.service';
|
||||||
import { TenderSchedulerService } from './tender-scheduler.service';
|
import { TenderSchedulerService } from './tender-scheduler.service';
|
||||||
|
import { TendersController } from './tenders.controller';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NestJS module for the Ausschreibungs-Radar feature.
|
* 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
|
* 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
|
* 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
|
* 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,
|
* (TenderIngestionService: day-cursor gate, SCHEMA-02 change detection,
|
||||||
* D-05 retention) and the shared global scheduler. Controller/DTO wiring
|
* D-05 retention) and the shared global scheduler. This plan (05) wires
|
||||||
* is added in Plan 05.
|
* 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).
|
* PrismaModule is global (no explicit import needed).
|
||||||
*
|
*
|
||||||
@@ -26,7 +29,7 @@ import { TenderSchedulerService } from './tender-scheduler.service';
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ModuleRegistryModule],
|
imports: [ModuleRegistryModule],
|
||||||
controllers: [],
|
controllers: [TendersController],
|
||||||
providers: [
|
providers: [
|
||||||
DoeOpenDataAdapter,
|
DoeOpenDataAdapter,
|
||||||
TenderNormalizerService,
|
TenderNormalizerService,
|
||||||
|
|||||||
Reference in New Issue
Block a user