Files
tessera-ctl/apps/web/src/lib/tender-radar-api.ts
T
schalli c4db3b2e65 feat(quick-260907-let): Knopf "Verbindung testen" im Postfach-Formular
- 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
2026-09-07 15:43:51 +02:00

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();
}