feat(14-03): add email-config admin routes + D-13 read-side visibility filter + EmailAlertConfigForm
buildTenderWhere gains an optional ownerTenantId param: a resolved
requesting tenant sees global tenders (null) plus its own private ones
(OR[global, mine]); an unresolved requester fails CLOSED to global-only —
never an accidental cross-tenant leak.
TendersController: listTenders/getTender resolve the requesting tenant
leniently from the auth context (resolveRequestingTenantId, never throws)
and apply the D-13 filter; getTender 404s (not a distinct "forbidden") when
a tender's non-null ownerTenantId doesn't match the requester, so no
cross-tenant detail leak. New GET/PUT /modules/tender-radar/email-config
routes (Roles ADMIN/SUPER_ADMIN, tenantId from auth context, never the
body) delegate to TenderEmailConfigService — declared before @Get(':id')
per the project's NestJS route-order convention.
Web: EmailAlertConfig type + fetchEmailConfig/saveEmailConfig client
functions; EmailAlertConfigForm mirrors the DKV InboxConfigForm (password
blank on load, only sent when typed — T-07-12), added as a new
"E-Mail-Alerts" section on the existing tender-radar settings page.
Hardcoded German strings — i18n is Plan 14-05.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -20,9 +20,11 @@ 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 { TenderEmailConfigService } from './tender-email-config.service';
|
||||
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
||||
import { TenderRssFeedSourceService } from './tender-rss-feed.service';
|
||||
import { TenderSavedSearchService } from './tender-saved-search.service';
|
||||
@@ -54,6 +56,16 @@ const DOE_SOURCE_TYPE = 'doe-opendata';
|
||||
* (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): `GET`/`PUT /email-config` are, like
|
||||
* `source-config`/`rss-feeds`, per-handler `@Roles(ADMIN, SUPER_ADMIN)`-
|
||||
* guarded — but UNLIKE those (platform-wide singletons/lists), the email
|
||||
* mailbox config is per-TENANT: tenantId is resolved from the auth context,
|
||||
* never the body (T-14-03-05/IDOR). This is also the plan that breaks the
|
||||
* "GET/GET :id are never row-scoped" invariant above, narrowly: `listTenders`/
|
||||
* `getTender` now resolve the requesting tenant to apply the D-13 OR[global,
|
||||
* mine] visibility filter for PRIVATE (email-alert) tenders only — public
|
||||
* tenders (D-03) remain visible to every tenant exactly as before.
|
||||
*/
|
||||
@Controller('modules/tender-radar')
|
||||
export class TendersController {
|
||||
@@ -64,6 +76,7 @@ export class TendersController {
|
||||
private readonly tenderSavedSearch: TenderSavedSearchService,
|
||||
private readonly tenderNotificationPref: TenderNotificationPrefService,
|
||||
private readonly tenderRssFeedSource: TenderRssFeedSourceService,
|
||||
private readonly tenderEmailConfig: TenderEmailConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -86,6 +99,18 @@ export class TendersController {
|
||||
return { userId, tenantId };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) ────────────────────
|
||||
|
||||
/**
|
||||
@@ -123,7 +148,11 @@ export class TendersController {
|
||||
favIds = await this.tenderTriage.favoriteIds(userId);
|
||||
}
|
||||
|
||||
const where = buildTenderWhere(query, favIds);
|
||||
// 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([
|
||||
@@ -202,6 +231,39 @@ export class TendersController {
|
||||
return this.tenderRssFeedSource.remove(feedId);
|
||||
}
|
||||
|
||||
// ─── E-Mail-Alerts config (Roles-guarded, PER-TENANT, D-06/D-07/D-13) ──────
|
||||
|
||||
/**
|
||||
* GET /modules/tender-radar/email-config — this tenant's portal-alert
|
||||
* mailbox config (safe-select — never the password, T-07-12). Unlike
|
||||
* `source-config`/`rss-feeds` (platform-wide), this is PER-TENANT:
|
||||
* tenantId is resolved from the auth context, never a query/body field
|
||||
* (T-14-03-05 / 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')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async getEmailConfig(@Req() req: Request) {
|
||||
const { tenantId } = this.extractTriageContext(req);
|
||||
return this.tenderEmailConfig.getConfigForApi(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /modules/tender-radar/email-config — upsert this tenant's mailbox
|
||||
* config. tenantId comes exclusively from the auth context — `dto` never
|
||||
* carries a tenantId field (T-14-03-05 / V4 — IDOR). Credential
|
||||
* encrypt-preserve-empty semantics live in TenderEmailConfigService
|
||||
* (mirrors DkvService.saveConfig / T-07-12).
|
||||
*/
|
||||
@Put('email-config')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
|
||||
const { tenantId } = this.extractTriageContext(req);
|
||||
return this.tenderEmailConfig.saveConfig(tenantId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /modules/tender-radar/coverage — distribution of active tenders
|
||||
* by sourcePortal (D-12, UI-05). Feeds the frontend CoverageBanner so a
|
||||
@@ -399,7 +461,7 @@ export class TendersController {
|
||||
*/
|
||||
@Get(':id')
|
||||
@UseModule('tender-radar')
|
||||
async getTender(@Param('id') id: string) {
|
||||
async getTender(@Param('id') id: string, @Req() req?: Request) {
|
||||
const tender = await this.prisma.tender.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
@@ -411,6 +473,18 @@ export class TendersController {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user