Files
tessera-ctl/apps/api/src/tenders/tenders.controller.ts
T
schalli 166194fa04 feat(13-06): getTender include sources[] (SCHEMA-03 read surface)
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>
2026-07-23 08:55:02 +02:00

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