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