3bf550bc65
- 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
670 lines
28 KiB
TypeScript
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;
|
|
}
|
|
}
|