/** * 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 { 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 { 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? */ export async function listTenders( params: URLSearchParams, ): Promise { 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 { 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 { 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=` 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 { 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= */ export async function fetchTriage(ids: string[]): Promise { 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 { 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; instantAlert: boolean; createdAt: string; updatedAt: string; } /** Payload accepted by POST /modules/tender-radar/saved-searches. */ export interface CreateSavedSearchPayload { name: string; filters: Record; instantAlert?: boolean; } /** Payload accepted by PATCH /modules/tender-radar/saved-searches/:searchId. */ export interface UpdateSavedSearchPayload { name?: string; filters?: Record; 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 & { password?: string }, ): Promise { 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 & { 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(); }