166194fa04
GET /modules/tender-radar/:id now includes the TenderSource relation (sourcePortal, sourceUrl, sourceNoticeId) so a cross-source-deduped tender's detail response carries links to all its source portals, not just the single primary sourceUrl column. Route order unchanged (:id stays after all static routes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
405 lines
15 KiB
TypeScript
405 lines
15 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
ForbiddenException,
|
|
Get,
|
|
NotFoundException,
|
|
Param,
|
|
Patch,
|
|
Post,
|
|
Put,
|
|
Query,
|
|
Req,
|
|
} from '@nestjs/common';
|
|
import { Role } from '@prisma/client';
|
|
import { Request } from 'express';
|
|
import { Roles } from '../auth/decorators/roles.decorator';
|
|
import { UseModule } from '../module-registry/module.guard';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { UpdateNotificationPrefDto } from './dto/notification-pref.dto';
|
|
import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto';
|
|
import { SourceConfigDto } from './dto/source-config.dto';
|
|
import { TenderQueryDto } from './dto/tender-query.dto';
|
|
import { TenderTriageDto } from './dto/tender-triage.dto';
|
|
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
|
import { TenderSavedSearchService } from './tender-saved-search.service';
|
|
import { TenderSchedulerService } from './tender-scheduler.service';
|
|
import { TenderTriageService } from './tender-triage.service';
|
|
import { buildOrderBy, buildTenderWhere } from './tender-query.builder';
|
|
|
|
/**
|
|
* T-11-11 (DoS): bounds the `ids` batch-triage query param — same
|
|
* defensive intent as MAX_FAV_IDS in tender-query.builder.ts.
|
|
*/
|
|
const MAX_TRIAGE_BATCH_IDS = 200;
|
|
|
|
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,
|
|
private readonly tenderTriage: TenderTriageService,
|
|
private readonly tenderSavedSearch: TenderSavedSearchService,
|
|
private readonly tenderNotificationPref: TenderNotificationPrefService,
|
|
) {}
|
|
|
|
/**
|
|
* Extracts (userId, tenantId) for the per-user Triage routes — same
|
|
* pattern as FavoritesController.extractContext (T-08-06): userId/
|
|
* tenantId are ALWAYS read from the authenticated request context, never
|
|
* from a client-supplied body/query field (T-11-10 / V4 — IDOR).
|
|
*/
|
|
private extractTriageContext(req: Request) {
|
|
const userId = (req as any).user?.id;
|
|
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
|
|
|
|
if (!tenantId) {
|
|
throw new ForbiddenException('No tenant context');
|
|
}
|
|
if (!userId) {
|
|
throw new ForbiddenException('No user context');
|
|
}
|
|
|
|
return { userId, tenantId };
|
|
}
|
|
|
|
// ─── Global read (ModuleGuard-gated, NOT tenant-scoped) ────────────────────
|
|
|
|
/**
|
|
* 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).
|
|
*
|
|
* favOnly (UI-04, T-11-10): when set, this user's favorited tenderIds
|
|
* are resolved server-side via TenderTriageService.favoriteIds(userId)
|
|
* — derived from the auth context, NOT from the query string — and
|
|
* passed into buildTenderWhere so an empty favorites list yields zero
|
|
* matches rather than the unfiltered catalog.
|
|
*/
|
|
@Get()
|
|
@UseModule('tender-radar')
|
|
async listTenders(@Query() query: TenderQueryDto, @Req() req?: Request) {
|
|
const page = query.page ?? 1;
|
|
const limit = query.limit ?? 20;
|
|
const skip = (page - 1) * limit;
|
|
|
|
let favIds: string[] | undefined;
|
|
if (query.favOnly) {
|
|
// req is always present in production (NestJS @Req() DI) — the
|
|
// optional type only accommodates unit tests that call this method
|
|
// directly without favOnly set (T-11-10: extractTriageContext
|
|
// throws ForbiddenException if req/user context is genuinely absent).
|
|
const { userId } = this.extractTriageContext(req as Request);
|
|
favIds = await this.tenderTriage.favoriteIds(userId);
|
|
}
|
|
|
|
const where = buildTenderWhere(query, favIds);
|
|
const orderBy = buildOrderBy(query.sort);
|
|
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.tender.findMany({
|
|
where,
|
|
orderBy,
|
|
skip,
|
|
take: limit,
|
|
}),
|
|
this.prisma.tender.count({ where }),
|
|
]);
|
|
|
|
return { items, total, page, limit };
|
|
}
|
|
|
|
/**
|
|
* GET /modules/tender-radar/source-config — read the singleton
|
|
* doe-opendata poll config.
|
|
*
|
|
* MUST be declared before the `:id` route below — NestJS matches routes
|
|
* in declaration order, so a `@Get(':id')` placed first would capture
|
|
* "source-config" as an id and shadow this handler (404 on GET).
|
|
*/
|
|
@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;
|
|
}
|
|
|
|
/**
|
|
* 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/triage?ids=<csv> — batch-fetch the current
|
|
* user's triage state (gelesen/ungelesen, Favorit) for the given
|
|
* tenderIds (UI-03/04). Used by the Trefferliste to merge triage state
|
|
* into the visible page in one round-trip instead of per-row requests.
|
|
*
|
|
* MUST be declared before `@Get(':id')` below — same route-order
|
|
* pitfall as `source-config`/`coverage` above (Pitfall 5).
|
|
*
|
|
* Scoped strictly by userId (T-11-10 / V4 — IDOR): userId is derived
|
|
* from the auth context, never from `ids`. `ids` is a client-supplied
|
|
* comma-separated list of tenderIds to look up — bounded to
|
|
* MAX_TRIAGE_BATCH_IDS entries (T-11-11, DoS).
|
|
*/
|
|
@Get('triage')
|
|
@UseModule('tender-radar')
|
|
async listTriage(@Query('ids') ids: string | undefined, @Req() req: Request) {
|
|
const { userId } = this.extractTriageContext(req);
|
|
const tenderIds = (ids ?? '')
|
|
.split(',')
|
|
.map((id) => id.trim())
|
|
.filter(Boolean)
|
|
.slice(0, MAX_TRIAGE_BATCH_IDS);
|
|
|
|
return this.tenderTriage.listForUser(userId, tenderIds);
|
|
}
|
|
|
|
/**
|
|
* PUT /modules/tender-radar/triage — upsert the current user's triage
|
|
* state (isRead/isFavorite) for one tender (UI-03/04). Idempotent
|
|
* (TenderTriageService.setTriage upserts on @@unique([userId,tenderId])).
|
|
*
|
|
* MUST be declared before `@Get(':id')` below (Pitfall 5).
|
|
*
|
|
* userId/tenantId come exclusively from the auth context — `dto` (body)
|
|
* carries only `tenderId`/`isRead`/`isFavorite`, never a userId field
|
|
* (T-11-10 / V4 — IDOR).
|
|
*/
|
|
@Put('triage')
|
|
@UseModule('tender-radar')
|
|
async setTriage(@Body() dto: TenderTriageDto, @Req() req: Request) {
|
|
const { userId, tenantId } = this.extractTriageContext(req);
|
|
|
|
return this.tenderTriage.setTriage(userId, tenantId, dto.tenderId, {
|
|
isRead: dto.isRead,
|
|
isFavorite: dto.isFavorite,
|
|
});
|
|
}
|
|
|
|
// ─── Saved Searches (per-user, FILTER-06, D-08/D-11) ───────────────────────
|
|
|
|
/**
|
|
* GET /modules/tender-radar/saved-searches — list the current user's
|
|
* saved search profiles (FILTER-06). Scoped strictly by userId
|
|
* (T-11-14 / V4 — IDOR), derived from the auth context, never from a
|
|
* query param.
|
|
*
|
|
* MUST be declared before `@Get(':id')` below — same route-order pitfall
|
|
* as `source-config`/`coverage`/`triage` above (Pitfall 5, T-11-16).
|
|
*/
|
|
@Get('saved-searches')
|
|
@UseModule('tender-radar')
|
|
async listSavedSearches(@Req() req: Request) {
|
|
const { userId } = this.extractTriageContext(req);
|
|
return this.tenderSavedSearch.list(userId);
|
|
}
|
|
|
|
/**
|
|
* POST /modules/tender-radar/saved-searches — create a new saved search
|
|
* profile. userId/tenantId come exclusively from the auth context
|
|
* (T-11-14 / V4 — IDOR); `dto` carries only name/filters, never a
|
|
* userId field.
|
|
*/
|
|
@Post('saved-searches')
|
|
@UseModule('tender-radar')
|
|
async createSavedSearch(
|
|
@Body() dto: CreateSavedSearchDto,
|
|
@Req() req: Request,
|
|
) {
|
|
const { userId, tenantId } = this.extractTriageContext(req);
|
|
return this.tenderSavedSearch.create(userId, tenantId, dto);
|
|
}
|
|
|
|
/**
|
|
* PATCH /modules/tender-radar/saved-searches/:searchId — rename and/or
|
|
* update the filters of an existing saved search. Ownership is verified
|
|
* in TenderSavedSearchService.update() (T-11-14). Uses `:searchId`
|
|
* (not `:id`) so this route can never be confused with the Tender
|
|
* `:id` param below (Pitfall 5).
|
|
*/
|
|
@Patch('saved-searches/:searchId')
|
|
@UseModule('tender-radar')
|
|
async updateSavedSearch(
|
|
@Param('searchId') searchId: string,
|
|
@Body() dto: UpdateSavedSearchDto,
|
|
@Req() req: Request,
|
|
) {
|
|
const { userId } = this.extractTriageContext(req);
|
|
return this.tenderSavedSearch.update(searchId, userId, dto);
|
|
}
|
|
|
|
/**
|
|
* DELETE /modules/tender-radar/saved-searches/:searchId — delete a saved
|
|
* search. Ownership verified in TenderSavedSearchService.remove()
|
|
* (T-11-14).
|
|
*/
|
|
@Delete('saved-searches/:searchId')
|
|
@UseModule('tender-radar')
|
|
async removeSavedSearch(
|
|
@Param('searchId') searchId: string,
|
|
@Req() req: Request,
|
|
) {
|
|
const { userId } = this.extractTriageContext(req);
|
|
await this.tenderSavedSearch.remove(searchId, userId);
|
|
return { success: true };
|
|
}
|
|
|
|
// ─── Notification preference (per-user, NOTIFY-01, D-01/D-03) ─────────────
|
|
|
|
/**
|
|
* GET /modules/tender-radar/notification-pref — this user's digest
|
|
* interval preference (daily/weekly/off). Scoped strictly by userId
|
|
* (T-12-14 / V4 — IDOR), derived from the auth context, never from a
|
|
* query param.
|
|
*
|
|
* MUST be declared before `@Get(':id')` below — same route-order pitfall
|
|
* as `source-config`/`coverage`/`triage`/`saved-searches` above
|
|
* (Pitfall 5).
|
|
*/
|
|
@Get('notification-pref')
|
|
@UseModule('tender-radar')
|
|
async getNotificationPref(@Req() req: Request) {
|
|
const { userId } = this.extractTriageContext(req);
|
|
return this.tenderNotificationPref.getForUser(userId);
|
|
}
|
|
|
|
/**
|
|
* PUT /modules/tender-radar/notification-pref — upsert this user's digest
|
|
* interval preference. userId/tenantId come exclusively from the auth
|
|
* context (T-12-14 / V4 — IDOR); `dto` carries only `digestInterval`,
|
|
* never a userId field.
|
|
*/
|
|
@Put('notification-pref')
|
|
@UseModule('tender-radar')
|
|
async setNotificationPref(
|
|
@Body() dto: UpdateNotificationPrefDto,
|
|
@Req() req: Request,
|
|
) {
|
|
const { userId, tenantId } = this.extractTriageContext(req);
|
|
return this.tenderNotificationPref.setForUser(userId, tenantId, dto.digestInterval);
|
|
}
|
|
|
|
/**
|
|
* GET /modules/tender-radar/:id — single tender detail.
|
|
* Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id
|
|
* (global row).
|
|
*
|
|
* D-03/SCHEMA-03 (13-06): includes the `sources` relation (TenderSource
|
|
* rows) so a cross-source-deduplicated tender surfaces links to ALL of
|
|
* its source portals, not just the single primary `sourceUrl` column.
|
|
* `select` is scoped to the three display fields the frontend needs —
|
|
* no `id`/`createdAt` leak, same minimal-surface convention as
|
|
* `getCoverage`'s groupBy projection.
|
|
*/
|
|
@Get(':id')
|
|
@UseModule('tender-radar')
|
|
async getTender(@Param('id') id: string) {
|
|
const tender = await this.prisma.tender.findUnique({
|
|
where: { id },
|
|
include: {
|
|
sources: {
|
|
select: { sourcePortal: true, sourceUrl: true, sourceNoticeId: true },
|
|
},
|
|
},
|
|
});
|
|
if (!tender) {
|
|
throw new NotFoundException('Tender not found');
|
|
}
|
|
return tender;
|
|
}
|
|
|
|
// ─── Admin source-config (Roles-guarded, live scheduler apply) ────────────
|
|
// NOTE: GET /source-config is declared above the `:id` route (route-order
|
|
// matters in NestJS). The PUT below is not shadowed — there is no @Put(':id').
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|