c4db3b2e65
- testEmailConnection im API-Klienten, POST email-config/test - EmailAlertConfigForm: Testknopf vor Speichern, Wartezustand, gruene/rote Rueckmeldung, Formularaenderung raeumt vorherige Rueckmeldung weg - Beschreibungsblock der Komponente korrigiert (Knopf existiert jetzt) - Vier neue Schluessel unter tenderRadar.emailAlerts in de/en - vi.mock-Fabriken in EmailAlertConfigForm.test.tsx und my-sources.test.tsx um testEmailConnection erweitert, zwei neue Testfaelle Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
596 lines
20 KiB
TypeScript
596 lines
20 KiB
TypeScript
/**
|
|
* Ausschreibungs-Radar (tender-radar) module API client.
|
|
* Consumes the /modules/tender-radar/* REST surface built in Plan 05.
|
|
* All calls use credentials: 'include' for cookie-based auth.
|
|
*
|
|
* Security: the DÖE source config carries no secrets (no username/password
|
|
* field, unlike DkvConfig) — T-10-18 accepts this as low-risk information
|
|
* disclosure since the config fetch response is auth-free by nature.
|
|
*/
|
|
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
|
|
|
// --- Types mirroring the Plan 05 backend contract (TenderSourcePollConfig) ---
|
|
|
|
/**
|
|
* Singleton `doe-opendata` poll config.
|
|
* `pollIntervalMin` is the cron-tick frequency (D-04 default 60) — NOT the
|
|
* DÖE fetch frequency, which is gated separately by the day-cursor inside
|
|
* the backend's TenderIngestionService (day-granular source).
|
|
*/
|
|
export interface SourceConfig {
|
|
/** Fixed slug identifying the source, e.g. 'doe-opendata'. Read-only display field. */
|
|
sourceType: string;
|
|
pollIntervalMin: number;
|
|
isActive: boolean;
|
|
/** Day-cursor of the last successfully ingested day. Read-only display field. */
|
|
lastIngestedDay?: string | null;
|
|
}
|
|
|
|
/** Payload accepted by PUT /modules/tender-radar/source-config. */
|
|
export interface SaveSourceConfigPayload {
|
|
pollIntervalMin: number;
|
|
isActive: boolean;
|
|
}
|
|
|
|
/**
|
|
* A single tender row from the global `Tender` catalog (Plan 11-01).
|
|
* `estimatedValue` arrives as a string (Prisma Decimal serializes via
|
|
* `toJSON()` -> `toString()`), NOT a number — never `Number()`-coerce it
|
|
* without a null check (D-05: 91.6% of rows are null, "keine Wertangabe").
|
|
* Date fields arrive as ISO strings (JSON has no native Date type).
|
|
*/
|
|
export interface Tender {
|
|
id: string;
|
|
sourcePortal: string;
|
|
title: string;
|
|
buyerName: string | null;
|
|
cpvCodes: string[];
|
|
region: string | null;
|
|
plz: string | null;
|
|
bundesland: string | null;
|
|
deadlineAt: string | null;
|
|
estimatedValue: string | null;
|
|
procedureType: string | null;
|
|
status: string;
|
|
sourceUrl: string | null;
|
|
publishedAt: string;
|
|
/**
|
|
* Cross-source-dedup links (13-06, SCHEMA-03, D-03): all TenderSource
|
|
* rows for this tender, one per portal it was found on. Optional —
|
|
* only `getTender` (detail) populates it; `listTenders` rows and
|
|
* older responses may omit it entirely, so callers must fall back to
|
|
* the single `sourceUrl` field when it's missing/empty.
|
|
*/
|
|
sources?: {
|
|
sourcePortal: string;
|
|
sourceUrl: string | null;
|
|
sourceNoticeId: string;
|
|
}[];
|
|
}
|
|
|
|
/** Response shape of GET /modules/tender-radar. */
|
|
export interface ListTendersResponse {
|
|
items: Tender[];
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
}
|
|
|
|
/** Response shape of GET /modules/tender-radar/coverage (D-12, UI-05). */
|
|
export interface CoverageResponse {
|
|
total: number;
|
|
sources: Array<{ sourcePortal: string; count: number }>;
|
|
}
|
|
|
|
/**
|
|
* A single AGB-denylisted portal (UI-06/D-12) — vergabe24/aumass must be
|
|
* watched manually. `url` is the canonical direct-link, sourced server-side
|
|
* from `PORTAL_URLS` (source-registry.ts), never hardcoded here.
|
|
*/
|
|
export interface DenylistedPortal {
|
|
portal: string;
|
|
url: string;
|
|
}
|
|
|
|
/** Response shape of GET /modules/tender-radar/denylisted-portals (UI-06/D-12). */
|
|
export interface DenylistedPortalsResponse {
|
|
portals: DenylistedPortal[];
|
|
}
|
|
|
|
// --- API functions ---
|
|
|
|
/**
|
|
* Fetch the current shared DÖE source poll config.
|
|
* GET /modules/tender-radar/source-config
|
|
*/
|
|
export async function fetchSourceConfig(): Promise<SourceConfig> {
|
|
const res = await fetch(`${API_URL}/modules/tender-radar/source-config`, {
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) throw new Error('Failed to fetch tender-radar source config');
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Save the shared DÖE source poll config.
|
|
* PUT /modules/tender-radar/source-config
|
|
* Saving live-applies the change to the running scheduler (INGEST-06,
|
|
* no restart required — see Plan 05).
|
|
*/
|
|
export async function saveSourceConfig(
|
|
payload: SaveSourceConfigPayload,
|
|
): Promise<SourceConfig> {
|
|
const res = await fetch(`${API_URL}/modules/tender-radar/source-config`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) throw new Error('Failed to save tender-radar source config');
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* List the global tender catalog with the given filter/sort/pagination
|
|
* params (Plan 11-01, FILTER-01/04/05, UI-01). `params` mirrors the
|
|
* TenderQueryDto query-string contract exactly — the URL is the single
|
|
* source of truth for filter state (Research: URL-Param-getriebener Client).
|
|
* GET /modules/tender-radar?<params>
|
|
*/
|
|
export async function listTenders(
|
|
params: URLSearchParams,
|
|
): Promise<ListTendersResponse> {
|
|
const query = params.toString();
|
|
const res = await fetch(
|
|
`${API_URL}/modules/tender-radar${query ? `?${query}` : ''}`,
|
|
{ credentials: 'include' },
|
|
);
|
|
if (!res.ok) throw new Error('Failed to fetch tenders');
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Trigger an immediate manual poll of all DUE tender sources (Quick
|
|
* 260723-lvg — "Jetzt abrufen"). Mirrors the DKV `checkNow` client shape.
|
|
* This is honest "fällige Quellen jetzt abrufen": 'day'-granularity
|
|
* sources still honor their nextDayToFetch cursor server-side — this is
|
|
* NOT a forced re-download.
|
|
* POST /modules/tender-radar/poll-now
|
|
*/
|
|
export async function pollNow(): Promise<{ ok: boolean }> {
|
|
const res = await fetch(`${API_URL}/modules/tender-radar/poll-now`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) throw new Error('Failed to trigger tender poll');
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Fetch the active-tender distribution by sourcePortal (D-12, UI-05) —
|
|
* feeds CoverageBanner so a thin/single-source result list is not
|
|
* misread as a defect.
|
|
* GET /modules/tender-radar/coverage
|
|
*/
|
|
export async function fetchCoverage(): Promise<CoverageResponse> {
|
|
const res = await fetch(`${API_URL}/modules/tender-radar/coverage`, {
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) throw new Error('Failed to fetch tender-radar coverage');
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Fetch the AGB-denylisted portals (vergabe24, aumass) with their canonical
|
|
* direct-link URLs (UI-06/D-12) — feeds CoverageBanner's "manuell
|
|
* beobachten" block.
|
|
* GET /modules/tender-radar/denylisted-portals
|
|
*/
|
|
export async function fetchDenylistedPortals(): Promise<DenylistedPortalsResponse> {
|
|
const res = await fetch(`${API_URL}/modules/tender-radar/denylisted-portals`, {
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) throw new Error('Failed to fetch denylisted portals');
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Fetch a single tender's full detail (Plan 11-04, UI-02/D-07) for the
|
|
* `?tender=<id>` in-component detail view. `rawPayload` is 100% NULL in
|
|
* the live DB (Research) — no stored document URLs exist, so the detail
|
|
* view only ever links to `sourceUrl`; there is no live document fetch.
|
|
* GET /modules/tender-radar/:id
|
|
*/
|
|
export async function getTender(id: string): Promise<Tender> {
|
|
const res = await fetch(`${API_URL}/modules/tender-radar/${id}`, {
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) throw new Error('Failed to fetch tender detail');
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* A single per-user triage row (Plan 11-05, UI-03/04). Per-user, NOT
|
|
* tenant-wide (D-11) — the backend derives userId from the auth cookie,
|
|
* this client never sends a userId.
|
|
*/
|
|
export interface TriageEntry {
|
|
tenderId: string;
|
|
isRead: boolean;
|
|
isFavorite: boolean;
|
|
}
|
|
|
|
/** Payload accepted by PUT /modules/tender-radar/triage. */
|
|
export interface SetTriagePayload {
|
|
tenderId: string;
|
|
isRead?: boolean;
|
|
isFavorite?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Batch-fetch this user's triage state (gelesen/ungelesen, Favorit) for a
|
|
* set of tenderIds (Plan 11-05, UI-03/04) — used by ResultsList to merge
|
|
* triage state into the visible page in one round-trip.
|
|
* GET /modules/tender-radar/triage?ids=<csv>
|
|
*/
|
|
export async function fetchTriage(ids: string[]): Promise<TriageEntry[]> {
|
|
if (!ids.length) return [];
|
|
const params = new URLSearchParams({ ids: ids.join(',') });
|
|
const res = await fetch(
|
|
`${API_URL}/modules/tender-radar/triage?${params}`,
|
|
{ credentials: 'include' },
|
|
);
|
|
if (!res.ok) throw new Error('Failed to fetch tender triage');
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Upsert this user's triage state for one tender (Plan 11-05, UI-03/04).
|
|
* Idempotent on the backend (@@unique([userId,tenderId])).
|
|
* PUT /modules/tender-radar/triage
|
|
*/
|
|
export async function setTriage(
|
|
payload: SetTriagePayload,
|
|
): Promise<TriageEntry> {
|
|
const res = await fetch(`${API_URL}/modules/tender-radar/triage`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) throw new Error('Failed to save tender triage');
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* A single per-user saved search profile (Plan 11-06, FILTER-06). Per-user,
|
|
* NOT tenant-wide (D-11) — the backend derives userId from the auth cookie,
|
|
* this client never sends a userId. `filters` mirrors the FilterPanel's URL
|
|
* searchParams contract exactly (q, plz, bundesland, region, cpv,
|
|
* deadlineFrom/To, openOnly, valueMin/Max, includeNullValue, sort, favOnly)
|
|
* — see SavedSearchBar.tsx's serialize/deserialize helpers for the
|
|
* round-trip contract.
|
|
*
|
|
* `instantAlert` (Plan 12-04, NOTIFY-02, D-04): per-profile Sofort-Alert
|
|
* toggle, default false — set via createSavedSearch/updateSavedSearch.
|
|
*/
|
|
export interface SavedSearch {
|
|
id: string;
|
|
name: string;
|
|
filters: Record<string, unknown>;
|
|
instantAlert: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
/** Payload accepted by POST /modules/tender-radar/saved-searches. */
|
|
export interface CreateSavedSearchPayload {
|
|
name: string;
|
|
filters: Record<string, unknown>;
|
|
instantAlert?: boolean;
|
|
}
|
|
|
|
/** Payload accepted by PATCH /modules/tender-radar/saved-searches/:searchId. */
|
|
export interface UpdateSavedSearchPayload {
|
|
name?: string;
|
|
filters?: Record<string, unknown>;
|
|
instantAlert?: boolean;
|
|
}
|
|
|
|
/**
|
|
* List this user's saved search profiles (Plan 11-06, FILTER-06).
|
|
* GET /modules/tender-radar/saved-searches
|
|
*/
|
|
export async function listSavedSearches(): Promise<SavedSearch[]> {
|
|
const res = await fetch(`${API_URL}/modules/tender-radar/saved-searches`, {
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) throw new Error('Failed to fetch saved searches');
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Create a new saved search profile from the currently active filters.
|
|
* POST /modules/tender-radar/saved-searches
|
|
*/
|
|
export async function createSavedSearch(
|
|
payload: CreateSavedSearchPayload,
|
|
): Promise<SavedSearch> {
|
|
const res = await fetch(`${API_URL}/modules/tender-radar/saved-searches`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) throw new Error('Failed to create saved search');
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Rename and/or update the filters of an existing saved search profile.
|
|
* PATCH /modules/tender-radar/saved-searches/:searchId
|
|
*/
|
|
export async function updateSavedSearch(
|
|
id: string,
|
|
payload: UpdateSavedSearchPayload,
|
|
): Promise<SavedSearch> {
|
|
const res = await fetch(
|
|
`${API_URL}/modules/tender-radar/saved-searches/${id}`,
|
|
{
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify(payload),
|
|
},
|
|
);
|
|
if (!res.ok) throw new Error('Failed to update saved search');
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Delete a saved search profile.
|
|
* DELETE /modules/tender-radar/saved-searches/:searchId
|
|
*/
|
|
export async function deleteSavedSearch(id: string): Promise<void> {
|
|
const res = await fetch(
|
|
`${API_URL}/modules/tender-radar/saved-searches/${id}`,
|
|
{ method: 'DELETE', credentials: 'include' },
|
|
);
|
|
if (!res.ok) throw new Error('Failed to delete saved search');
|
|
}
|
|
|
|
/**
|
|
* This user's digest interval preference (Plan 12-04, NOTIFY-01, D-01/D-03).
|
|
* Per-user, NOT per-profile — the backend derives userId from the auth
|
|
* cookie, this client never sends a userId.
|
|
*/
|
|
export interface NotificationPref {
|
|
digestInterval: 'daily' | 'weekly' | 'off';
|
|
}
|
|
|
|
/**
|
|
* Fetch this user's digest interval preference. Defaults to 'daily' on the
|
|
* backend when no row exists yet (D-01) — this client just relays whatever
|
|
* the backend returns.
|
|
* GET /modules/tender-radar/notification-pref
|
|
*/
|
|
export async function fetchNotificationPref(): Promise<NotificationPref> {
|
|
const res = await fetch(`${API_URL}/modules/tender-radar/notification-pref`, {
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) throw new Error('Failed to fetch notification preference');
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Save this user's digest interval preference.
|
|
* PUT /modules/tender-radar/notification-pref
|
|
*/
|
|
export async function saveNotificationPref(
|
|
digestInterval: NotificationPref['digestInterval'],
|
|
): Promise<NotificationPref> {
|
|
const res = await fetch(`${API_URL}/modules/tender-radar/notification-pref`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ digestInterval }),
|
|
});
|
|
if (!res.ok) throw new Error('Failed to save notification preference');
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* A single RSS feed source — either platform-wide (admin-managed, D-08/
|
|
* D-14) or personal (owned by exactly one user, Phase 17 Plan 02, D-02).
|
|
* `isPlatformWide` is a SERVER-DERIVED display flag (T-17-12) — the raw
|
|
* ownership `userId` never reaches the client; the UI only needs to know
|
|
* whether a row is editable by the current caller, not who owns it.
|
|
*/
|
|
export interface RssFeedSource {
|
|
id: string;
|
|
url: string;
|
|
label: string;
|
|
isActive: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
/** true = maintained by the administration, applies to everyone; false = the caller's own personal feed. */
|
|
isPlatformWide: boolean;
|
|
}
|
|
|
|
/** Payload accepted by POST /modules/tender-radar/rss-feeds (scope goes as a separate function argument, see `createRssFeed`). */
|
|
export interface CreateRssFeedPayload {
|
|
url: string;
|
|
label: string;
|
|
isActive?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Extracts the backend's error message from a non-2xx JSON error body
|
|
* (Nest's default exception filter shape: `{ statusCode, message, error }`)
|
|
* so the save-time denylist/SSRF rejection (D-14, T-14-02-01) surfaces its
|
|
* specific reason inline instead of a generic "failed to save" string.
|
|
*/
|
|
async function extractErrorMessage(res: Response, fallback: string): Promise<string> {
|
|
try {
|
|
const body = (await res.json()) as { message?: unknown };
|
|
if (typeof body.message === 'string' && body.message) return body.message;
|
|
if (Array.isArray(body.message) && body.message.length) {
|
|
return body.message.join(', ');
|
|
}
|
|
} catch {
|
|
/* body wasn't JSON — fall through to the generic message */
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
/**
|
|
* List every platform-wide RSS feed plus the caller's own personal feeds
|
|
* (Phase 17 Plan 02, D-02). Each entry carries `isPlatformWide`.
|
|
* GET /modules/tender-radar/rss-feeds
|
|
*/
|
|
export async function listRssFeeds(): Promise<RssFeedSource[]> {
|
|
const res = await fetch(`${API_URL}/modules/tender-radar/rss-feeds`, {
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(
|
|
await extractErrorMessage(res, 'Failed to fetch RSS feeds'),
|
|
);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Add a new RSS feed URL. `scope` is a WISH the server re-checks, never
|
|
* trusted by itself (Phase 17 Plan 02, D-02, T-17-08): `'platform'`
|
|
* requires the caller to hold ADMIN/SUPER_ADMIN and is rejected with 403
|
|
* otherwise; `'personal'` (the default) creates a feed owned by the
|
|
* caller. Rejected with the backend's specific hostname/SSRF-guard message
|
|
* (D-14) when the URL is denylisted/private/loopback, or with the
|
|
* per-user cap message once 20 personal feeds are reached (T-17-10) — the
|
|
* rejection message is relayed as-is via `extractErrorMessage` so the
|
|
* caller sees WHY, not just that the save failed.
|
|
* POST /modules/tender-radar/rss-feeds
|
|
*/
|
|
export async function createRssFeed(
|
|
payload: CreateRssFeedPayload,
|
|
scope: 'personal' | 'platform' = 'personal',
|
|
): Promise<RssFeedSource> {
|
|
const res = await fetch(`${API_URL}/modules/tender-radar/rss-feeds`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ ...payload, scope }),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(await extractErrorMessage(res, 'Failed to create RSS feed'));
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Remove an RSS feed. Ownership/authorization is enforced entirely on the
|
|
* server (T-17-07) — the caller may remove their own personal feed, or, if
|
|
* ADMIN/SUPER_ADMIN, a platform-wide feed.
|
|
* DELETE /modules/tender-radar/rss-feeds/:feedId
|
|
*/
|
|
export async function deleteRssFeed(id: string): Promise<void> {
|
|
const res = await fetch(`${API_URL}/modules/tender-radar/rss-feeds/${id}`, {
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) {
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* Test the connection to this user's own portal-alert mailbox WITHOUT
|
|
* saving anything (Quick 260907-let, WINDOWS #16). Same payload shape as
|
|
* `saveEmailConfig` — when `password`/`username` are left out, the server
|
|
* falls back to this user's stored, decrypted credentials.
|
|
* POST /modules/tender-radar/email-config/test
|
|
*/
|
|
export async function testEmailConnection(
|
|
payload: Partial<EmailAlertConfig> & { password?: string },
|
|
): Promise<{ success: boolean; message?: string }> {
|
|
const res = await fetch(`${API_URL}/modules/tender-radar/email-config/test`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(
|
|
await extractErrorMessage(res, 'Failed to test email-alert connection'),
|
|
);
|
|
}
|
|
return res.json();
|
|
}
|