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 { 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,
|
||||
|
||||
Reference in New Issue
Block a user