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:
2026-07-23 13:53:17 +02:00
parent 1be6b15249
commit 48e12523f3
8 changed files with 1042 additions and 2 deletions
@@ -31,10 +31,20 @@ const MAX_FAV_IDS = 500;
* `TenderTriageService.favoriteIds(userId)`) from the auth context —
* never accepted here as user input. Only consulted when `dto.favOnly` is
* true.
*
* `ownerTenantId` (Phase 14, Plan 03, D-13): the requesting tenant's id,
* resolved by the CALLER (TendersController) from the auth context — never
* from `dto`. Applies the OR[global, mine] visibility rule for privately-
* sourced (email-alert) tenders: `{ OR: [{ownerTenantId:null},
* {ownerTenantId}] }`. When the caller cannot resolve a requesting tenant
* (no auth context), this fails CLOSED — only globally-visible
* (`ownerTenantId: null`) tenders are returned, never a private tenant's
* rows leaked to an unidentified requester.
*/
export function buildTenderWhere(
dto: TenderQueryDto,
favIds?: string[],
ownerTenantId?: string,
): Prisma.TenderWhereInput {
const where: Prisma.TenderWhereInput = {};
const AND: Prisma.TenderWhereInput[] = [];
@@ -126,6 +136,16 @@ export function buildTenderWhere(
AND.push({ id: { in: ids.length ? ids : ['__none__'] } });
}
// D-13 (Phase 14, Plan 03): private (email-alert) tender visibility.
// A resolved requesting tenant sees global tenders (null) PLUS its own;
// an unidentified requester (ownerTenantId undefined) sees ONLY global
// tenders — fail-closed, never an accidental cross-tenant leak.
AND.push(
ownerTenantId
? { OR: [{ ownerTenantId: null }, { ownerTenantId }] }
: { ownerTenantId: null },
);
if (AND.length) where.AND = AND;
return where;
}