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
+65
View File
@@ -446,3 +446,68 @@ export async function deleteRssFeed(id: string): Promise<void> {
throw new Error(await extractErrorMessage(res, 'Failed to delete RSS feed'));
}
}
/**
* This tenant's portal-alert mailbox config (Plan 14-03, INGEST-05/
* CONFIG-02, D-06/D-07). Unlike SourceConfig/RssFeedSource (platform-wide),
* this is PER-TENANT — the backend derives tenantId from the auth cookie,
* this client never sends a tenantId.
*
* Security (T-07-12): the password is NEVER returned — only `hasPassword`.
* The password field is only sent in `saveEmailConfig`'s payload when the
* admin has typed a new one (same InboxConfigForm/DKV convention).
*/
export interface EmailAlertConfig {
protocol: 'imap' | 'exchange';
host: string | null;
port: number | null;
encryption: 'none' | 'starttls' | 'ssl-tls';
folder: string;
senderFilter?: string | null;
domain?: string | null;
isActive: boolean;
username?: string | null;
/** true if a password is stored server-side — never the actual secret */
hasPassword: boolean;
}
/**
* Fetch this tenant's email-alert mailbox config. Returns null when no
* config has been saved yet (fresh tenant — mirrors DkvConfig's fetchConfig
* convention).
* GET /modules/tender-radar/email-config
*/
export async function fetchEmailConfig(): Promise<EmailAlertConfig | null> {
const res = await fetch(`${API_URL}/modules/tender-radar/email-config`, {
credentials: 'include',
});
if (!res.ok) {
throw new Error(
await extractErrorMessage(res, 'Failed to fetch email-alert config'),
);
}
return res.json();
}
/**
* Save this tenant's email-alert mailbox config.
* PUT /modules/tender-radar/email-config
* Only include `password` in the payload when the admin typed a new one
* (T-07-12 — blank-on-load convention).
*/
export async function saveEmailConfig(
payload: Partial<EmailAlertConfig> & { password?: string },
): Promise<EmailAlertConfig> {
const res = await fetch(`${API_URL}/modules/tender-radar/email-config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(
await extractErrorMessage(res, 'Failed to save email-alert config'),
);
}
return res.json();
}