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
@@ -256,6 +256,26 @@ describe('buildTenderWhere — favOnly (UI-04, D-10, T-11-10/11)', () => {
});
});
describe('buildTenderWhere — D-13 ownerTenantId visibility (Phase 14, Plan 03)', () => {
it('a resolved ownerTenantId adds an OR[global, mine] clause', () => {
const where = buildTenderWhere(dto(), undefined, 'tenant-a');
expect(where.AND).toEqual(
expect.arrayContaining([
{ OR: [{ ownerTenantId: null }, { ownerTenantId: 'tenant-a' }] },
]),
);
});
it('an unresolved ownerTenantId (no auth context) fails closed to global-only tenders', () => {
const where = buildTenderWhere(dto());
expect(where.AND).toEqual(
expect.arrayContaining([{ ownerTenantId: null }]),
);
});
});
describe('buildOrderBy', () => {
it('sort=deadline maps to { deadlineAt: asc }', () => {
expect(buildOrderBy('deadline')).toEqual({ deadlineAt: 'asc' });