feat(260723-lvg): add admin-gated POST /poll-now endpoint for tender radar

Manual "Jetzt abrufen" trigger delegates to
TenderIngestionService.pollDueSources() — the same fan-out tick the
scheduler cron runs. Gated to ADMIN/SUPER_ADMIN (T-lvg-01, DoS) and
declared before @Get(':id') per the established route-order convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 15:52:44 +02:00
parent e1358d70fd
commit 7502f97e85
2 changed files with 88 additions and 0 deletions
@@ -26,6 +26,7 @@ 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';
@@ -78,6 +79,15 @@ export class TendersController {
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,
) {}
/**
@@ -189,6 +199,32 @@ export class TendersController {
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 admin CRUD (Roles-guarded, GLOBAL, D-08/D-14) ───────────────
/**