Files
tessera-ctl/apps/api/src/tenders/tenders.controller.ts
T
schalli 3bf550bc65 feat(quick-260907-let): Verbindungstest fuer Postfach-Endpunkt im API
- TenderEmailConfigService.testConnection(userId, dto) mit Rueckfall auf
  gespeicherte, entschluesselte Zugangsdaten bei leeren Feldern
- TendersController: POST email-config/test, userId aus Auth-Kontext,
  deklariert vor @Get(':id')
- Beide Provider (ImapProvider/ExchangeInboxProvider) optional angehaengt,
  bestehende 2-Arg-Konstruktoraufrufe bleiben typkorrekt
- Reihenfolge-Waechter und IDOR-Testfall ergaenzt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-07 15:40:28 +02:00

670 lines
28 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 { TenderEmailConfigDto } from './dto/tender-email-config.dto';
import { TenderQueryDto } from './dto/tender-query.dto';
import { TenderRssFeedDto } from './dto/tender-rss-feed.dto';
import { TenderTriageDto } from './dto/tender-triage.dto';
import { DENYLISTED_PORTALS, PORTAL_URLS } from './source-registry';
import { TenderEmailConfigService } from './tender-email-config.service';
import { TenderIngestionService } from './tender-ingestion.service';
import { TenderNotificationPrefService } from './tender-notification-pref.service';
import { TenderRssFeedSourceService } from './tender-rss-feed.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.
*
* Phase 14, Plan 03 (INGEST-05, D-13) originally made `GET`/`PUT
* /email-config` per-handler `@Roles(ADMIN, SUPER_ADMIN)`-guarded and
* per-TENANT. Phase 17, Plan 01 (D-01) changed this: the mailbox is now
* per-USER — every user with module access connects their own inbox, so
* these two routes are `@UseModule('tender-radar')`-gated like the other
* per-user routes below, and `userId` (not `tenantId`) is the ownership
* key, resolved from the auth context, never the body (T-14-03-05/T-17-01/
* IDOR). `listTenders`/`getTender` still resolve the requesting tenant to
* apply the D-13 OR[global, mine] visibility filter for PRIVATE
* (email-alert) tenders — public tenders (D-03) remain visible to every
* tenant exactly as before; that part of D-13 is unaffected by D-01.
*
* Phase 14, Plan 02 (INGEST-04, D-14) originally made `GET`/`POST`/`DELETE
* /rss-feeds` per-handler `@Roles(ADMIN, SUPER_ADMIN)`-guarded, GLOBAL-only.
* Phase 17, Plan 02 (D-02) changed this: the feed list is now two-part —
* platform-wide feeds (unchanged, admin-only to create/delete) and personal
* feeds any module user may create and delete for themselves. `GET`/`POST
* /rss-feeds` are `@UseModule('tender-radar')`-gated; `POST`'s
* `scope: 'platform'` path re-checks ADMIN/SUPER_ADMIN inline
* (`extractTriageContext`'s `role`, T-17-08) since the route itself is no
* longer admin-only. `DELETE /rss-feeds/:feedId` ownership enforcement is
* described at that handler.
*/
@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,
private readonly tenderRssFeedSource: TenderRssFeedSourceService,
private readonly tenderEmailConfig: TenderEmailConfigService,
/**
* Appended as the LAST constructor param (Quick 260723-lvg) — preserves
* every existing `new TendersController(...7 args...)` call site in the
* spec unchanged; those construct without this arg (undefined) and never
* exercise `pollNow`. Declared optional (`?`) so those 7-arg call sites
* still type-check — NestJS DI always resolves and injects it in
* production (it is a registered provider in tenders.module.ts).
*/
private readonly tenderIngestionService?: TenderIngestionService,
) {}
/**
* Extracts (userId, tenantId, role) for the per-user routes — same
* pattern as FavoritesController.extractContext (T-08-06): userId/
* tenantId/role are ALWAYS read from the authenticated request context,
* never from a client-supplied body/query field (T-11-10 / V4 — IDOR).
*
* `role` was added in Phase 17, Plan 02 (D-02): `createRssFeed` needs the
* caller's role to decide whether a `scope: 'platform'` request is
* allowed (T-17-08), read from the SAME place `RolesGuard` reads it
* (`roles.guard.ts`) — one single spot in this controller resolves
* account data from the request.
*/
private extractTriageContext(req: Request) {
const userId = (req as any).user?.id;
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
const role = (req as any).user?.role;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
if (!userId) {
throw new ForbiddenException('No user context');
}
return { userId, tenantId, role };
}
/**
* Leniently resolves the requesting tenant's id for the D-13 visibility
* filter — unlike extractTriageContext, this NEVER throws when the
* context is missing: `listTenders`/`getTender` are gated only by
* `@UseModule`, not per-user auth, and must degrade to "global tenders
* only" (fail-closed, tender-query.builder.ts) rather than 403 when no
* tenant context is present.
*/
private resolveRequestingTenantId(req?: Request): string | undefined {
return (req as any)?.user?.tenantId ?? (req as any)?.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);
}
// D-13: resolve the requesting tenant for the private-tender visibility
// filter — leniently (never throws); see resolveRequestingTenantId doc.
const ownerTenantId = this.resolveRequestingTenantId(req);
const where = buildTenderWhere(query, favIds, ownerTenantId);
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;
}
// ─── Admin manual poll trigger (Quick 260723-lvg) ──────────────────────────
/**
* POST /modules/tender-radar/poll-now — immediately run
* `TenderIngestionService.pollDueSources()`, the same fan-out tick the
* scheduler cron runs (INGEST-06). This fetches DUE sources now — it is
* NOT a forced re-download: `'day'`-granularity sources (doe-opendata,
* ai-netserver, cosinex-dtvp) still honor the `nextDayToFetch` day-cursor,
* so a click after today's day was already ingested is a no-op for those
* sources; `'tick'`-granularity sources (rss, email-alert) always fetch.
* `pollDueSources()` never throws (catch-and-log per tick + per source),
* so no try/catch is needed here.
*
* MUST be declared before `@Get(':id')` below — same route-order pitfall
* as `source-config`/`coverage`/`rss-feeds`/... above (Pitfall 5).
*
* T-lvg-01 (DoS): gated to admins only — the platform-wide upstream fetch
* is a rate lever, not a per-tenant module feature.
*/
@Post('poll-now')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async pollNow() {
await this.tenderIngestionService!.pollDueSources();
return { ok: true };
}
// ─── RSS-Feeds (ModuleGuard-gated, personal + platform-wide, Phase 17 D-02) ─
/**
* GET /modules/tender-radar/rss-feeds — every platform-wide feed plus the
* requesting user's own personal feeds (D-02). Phase 17: replaced
* `@Roles(ADMIN, SUPER_ADMIN)` with `@UseModule('tender-radar')` — every
* module user can see (and add) their own feeds now, not just admins.
* Each entry gets a derived `isPlatformWide` flag; the raw `userId`
* ownership field is stripped from the response (T-17-12) — the UI has
* no need for it.
*
* MUST be declared before `@Get(':id')` below — same route-order pitfall
* as `source-config`/`coverage`/`triage`/... above (Pitfall 5). Route
* position is UNCHANGED from before Phase 17 — no new route was added,
* only the guard and the handler body.
*/
@Get('rss-feeds')
@UseModule('tender-radar')
async listRssFeeds(@Req() req: Request) {
const { userId } = this.extractTriageContext(req);
const feeds = await this.tenderRssFeedSource.listForUser(userId);
return feeds.map(({ userId: ownerUserId, ...rest }) => ({
...rest,
isPlatformWide: ownerUserId === null,
}));
}
/**
* POST /modules/tender-radar/rss-feeds — add a feed. `dto.scope` is a
* WISH, never trusted by itself: `scope: 'platform'` additionally
* requires the caller to hold ADMIN/SUPER_ADMIN (T-17-08), checked here
* against the SAME `role` source `RolesGuard` reads
* (`extractTriageContext`); any other/omitted scope creates a personal
* feed owned by the caller (D-02). The save-time hostname/SSRF guard
* (D-14, T-14-02-01) lives in `TenderRssFeedSourceService` and runs
* unchanged on both paths — a denylisted/private-host URL still surfaces
* as a 400 (BadRequestException) here.
*/
@Post('rss-feeds')
@UseModule('tender-radar')
async createRssFeed(@Body() dto: TenderRssFeedDto, @Req() req: Request) {
const { userId, tenantId, role } = this.extractTriageContext(req);
if (dto.scope === 'platform') {
if (role !== Role.ADMIN && role !== Role.SUPER_ADMIN) {
throw new ForbiddenException(
'Nur Administratoren dürfen plattformweite RSS-Feeds anlegen.',
);
}
return this.tenderRssFeedSource.createPlatform(dto);
}
return this.tenderRssFeedSource.createForUser({ userId, tenantId }, dto);
}
/**
* DELETE /modules/tender-radar/rss-feeds/:feedId — remove a feed.
* Ownership is enforced entirely inside the service's single conditional
* `deleteMany` (T-17-07): the caller may delete their own feed, or — if
* ADMIN/SUPER_ADMIN — a platform-wide feed. Anything else (someone
* else's personal feed, or a non-admin targeting a platform-wide feed)
* surfaces as `NotFoundException`, never `ForbiddenException` — the
* response never confirms whether a foreign id exists. Phase 17: replaced
* `@Roles(ADMIN, SUPER_ADMIN)` with `@UseModule('tender-radar')` — every
* module user may reach this route now, the ownership check does the
* rest.
*
* Uses `:feedId` (not `:id`) so this route can never be confused with
* the Tender `:id` route below (Pitfall 5, same convention as
* `saved-searches/:searchId`). Route position UNCHANGED.
*/
@Delete('rss-feeds/:feedId')
@UseModule('tender-radar')
async removeRssFeed(@Param('feedId') feedId: string, @Req() req: Request) {
const { userId, role } = this.extractTriageContext(req);
const isAdmin = role === Role.ADMIN || role === Role.SUPER_ADMIN;
return this.tenderRssFeedSource.remove(feedId, { userId, isAdmin });
}
// ─── E-Mail-Alerts config (ModuleGuard-gated, PER-USER, Phase 17 D-01) ─────
/**
* GET /modules/tender-radar/email-config — the requesting USER's own
* portal-alert mailbox config (safe-select — never the password,
* T-07-12). Phase 17 (D-01): ownership moved from tenant to user — every
* user with module access manages their own mailbox, so the Roles guard
* (previously ADMIN/SUPER_ADMIN only) is replaced with `@UseModule`, the
* same access gate as every other per-user route below. `userId` comes
* exclusively from the auth context, never a query/body field
* (T-14-03-05 / T-17-01 / V4 — IDOR).
*
* MUST be declared before `@Get(':id')` below — same route-order pitfall
* as `source-config`/`coverage`/`triage`/`rss-feeds`/... above (Pitfall 5).
*/
@Get('email-config')
@UseModule('tender-radar')
async getEmailConfig(@Req() req: Request) {
const { userId } = this.extractTriageContext(req);
return this.tenderEmailConfig.getConfigForApi(userId);
}
/**
* PUT /modules/tender-radar/email-config — upsert the requesting USER's
* own mailbox config. userId/tenantId come exclusively from the auth
* context — `dto` never carries either field (T-14-03-05 / T-17-01 / V4
* — IDOR). Credential encrypt-preserve-empty semantics live in
* TenderEmailConfigService (mirrors DkvService.saveConfig / T-07-12).
*/
@Put('email-config')
@UseModule('tender-radar')
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.saveConfig({ userId, tenantId }, dto);
}
/**
* POST /modules/tender-radar/email-config/test — tests the connection to
* the requesting USER's own portal-alert mailbox WITHOUT saving anything
* (Quick 260907-let, WINDOWS #16). `userId` comes exclusively from the
* auth context, exactly like `getEmailConfig`/`saveEmailConfig` above —
* `dto` carries no ownership field at all, so a body value under any key
* can never redirect the test at a different user's mailbox
* (T-14-03-05 / T-17-01 / T-QT16-01 — IDOR).
*
* Declared directly after `saveEmailConfig`, keeping the whole
* email-config block together, and — like every other handler in this
* block — placed before `@Get(':id')` below. NestJS actually resolves
* routes per HTTP verb, so a `GET :id` placeholder could never shadow
* this `POST` route today; the ordering here is defensive consistency
* with the surrounding email-config handlers (and the guard test below),
* not a live 404 risk, in case a `POST :id`-shaped placeholder is ever
* added to this controller in the future.
*/
@Post('email-config/test')
@UseModule('tender-radar')
async testEmailConnection(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
const { userId } = this.extractTriageContext(req);
return this.tenderEmailConfig.testConnection(userId, dto);
}
/**
* 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/denylisted-portals — the AGB-prohibited
* portals (vergabe24, aumass) with their canonical direct-link URLs
* (UI-06/D-12). Maps over `DENYLISTED_PORTALS` (source-registry.ts) —
* the portal SET is never re-declared here, so a future denylist entry
* (with a URL added to `PORTAL_URLS`) flows through automatically.
*
* MUST be declared before `@Get(':id')` below — same route-order
* pitfall as `source-config`/`coverage`/... above (Pitfall 5). Read-
* surface, gated by @UseModule (not admin-only) — same stance as
* `getCoverage`.
*/
@Get('denylisted-portals')
@UseModule('tender-radar')
async getDenylistedPortals() {
return {
portals: DENYLISTED_PORTALS.map((portal) => ({
portal,
url: PORTAL_URLS[portal],
})),
};
}
/**
* 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, @Req() req?: Request) {
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');
}
// D-13: a privately-owned (email-alert) tender is invisible to every
// OTHER tenant — surfaced as the SAME NotFoundException as a genuinely
// missing id, never a distinct "forbidden" response (no cross-tenant
// detail leak, e.g. confirming the id exists at all).
if ((tender as { ownerTenantId?: string | null }).ownerTenantId) {
const requestingTenantId = this.resolveRequestingTenantId(req);
if ((tender as { ownerTenantId?: string | null }).ownerTenantId !== requestingTenantId) {
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;
}
}