feat(11-01): wire listTenders through the query builder + add GET /coverage
listTenders now delegates where/orderBy composition to
tender-query.builder.ts (buildTenderWhere/buildOrderBy) instead of the
fixed status/publishedAt clause; pagination bounds unchanged (T-10-15).
New GET /modules/tender-radar/coverage handler returns active-tender
counts grouped by sourcePortal (D-12, UI-05 coverage banner data
source). Declared before @Get(':id') — same static-route-before-:id
convention as source-config (Pitfall 5, Phase-10 regression guard).
Extends tenders.controller.spec.ts: sort whitelist pass-through,
unknown-sort fallback, pagination skip/take, coverage response shape,
and a declaration-order regression test for getCoverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ 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';
|
||||
import { buildOrderBy, buildTenderWhere } from './tender-query.builder';
|
||||
|
||||
const DOE_SOURCE_TYPE = 'doe-opendata';
|
||||
|
||||
@@ -44,27 +45,35 @@ export class TendersController {
|
||||
// ─── 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 /modules/tender-radar — paginated, filtered + sorted 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).
|
||||
*
|
||||
* Filter/sort composition is delegated to tender-query.builder.ts
|
||||
* (buildTenderWhere/buildOrderBy) — kept out of the controller so the
|
||||
* where/orderBy logic is independently unit-testable (T-11-01/03).
|
||||
* Pagination bounds (limit @Max(100), page @Min(1)) are unchanged
|
||||
* (T-10-15, Don't Hand-Roll).
|
||||
*/
|
||||
@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 where = buildTenderWhere(query);
|
||||
const orderBy = buildOrderBy(query.sort);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.tender.findMany({
|
||||
where: { status },
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
where,
|
||||
orderBy,
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.tender.count({ where: { status } }),
|
||||
this.prisma.tender.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, limit };
|
||||
@@ -90,6 +99,37 @@ export class TendersController {
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /modules/tender-radar/coverage — distribution of active tenders
|
||||
* by sourcePortal (D-12, UI-05). Feeds the frontend CoverageBanner so a
|
||||
* thin/single-source result list (currently only 'doe-opendata',
|
||||
* Oberschwelle-lastig) is not misread as a defect.
|
||||
*
|
||||
* MUST be declared before `@Get(':id')` below — same route-order
|
||||
* pitfall as `source-config` above (Pitfall 5, Phase-10 regression).
|
||||
* Read-surface, gated by @UseModule (not an admin-only route).
|
||||
*/
|
||||
@Get('coverage')
|
||||
@UseModule('tender-radar')
|
||||
async getCoverage() {
|
||||
const [bySource, total] = await Promise.all([
|
||||
this.prisma.tender.groupBy({
|
||||
by: ['sourcePortal'],
|
||||
_count: true,
|
||||
where: { status: 'active' },
|
||||
}),
|
||||
this.prisma.tender.count({ where: { status: 'active' } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
total,
|
||||
sources: bySource.map((s) => ({
|
||||
sourcePortal: s.sourcePortal,
|
||||
count: s._count,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /modules/tender-radar/:id — single tender detail.
|
||||
* Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id
|
||||
|
||||
Reference in New Issue
Block a user