/** * The typed API client. * * Replaces eight ad-hoc `fetch` calls spread across the dashboard, the settings * modal and the logger. Two things are deliberate here: * * 1. Every call returns a `Result` rather than throwing. Callers cannot ignore the * failure case, which is what let a dead backend previously render as the * "no records found" empty state. * 2. A 401 is a distinct variant. Previously `if (res.ok)` treated an auth failure * and an empty database identically, so a wrong key looked like no data. */ import { getSessionId } from "@/lib/session"; import { getSettings } from "@/lib/storage"; import type { BulkCreateResult, DeleteResult, EmotionLogInput, EmotionLog, LogQueryParams, LogsPage, LogStats, } from "@/types/log"; import type { HealthResponse } from "@/types/connection"; export type ApiErrorKind = | "unauthorized" | "not_found" | "rate_limited" | "validation" | "server" | "network" | "timeout" | "aborted"; export interface ApiError { kind: ApiErrorKind; status: number | null; message: string; requestId?: string; } export type Result = { ok: true; data: T } | { ok: false; error: ApiError }; const DEFAULT_TIMEOUT_MS = 15_000; /** * Resolve the API origin. * * Order matters. The build-time env var wins so the Docker image can be pointed at * a specific host; an empty value falls through to the current origin, which is * correct when the FastAPI process serves the exported bundle itself. The stored * override is last, for pointing a local dev frontend at a remote backend. * * Previously `process.env` was read nowhere in the app and the URL was hardcoded to * `http://localhost:8000`, which is why the container served a bundle that called * localhost while the server listened on 7860. */ export function resolveBaseUrl(override?: string): string { const stored = override ?? (typeof window !== "undefined" ? getSettings().backendUrl : ""); if (stored) return stored.replace(/\/$/, ""); const fromEnv = process.env.NEXT_PUBLIC_BACKEND_URL; if (fromEnv) return fromEnv.replace(/\/$/, ""); if (typeof window !== "undefined") return window.location.origin; return ""; } function errorKindFor(status: number): ApiErrorKind { if (status === 401 || status === 403) return "unauthorized"; if (status === 404) return "not_found"; if (status === 429) return "rate_limited"; if (status === 400 || status === 422) return "validation"; return "server"; } const MESSAGES: Record = { unauthorized: "The server rejected this request.", not_found: "Not found.", rate_limited: "Rate limit reached. Try again shortly.", validation: "The request was rejected as invalid.", server: "The server failed to handle the request.", network: "Could not reach the backend.", timeout: "The request timed out.", aborted: "The request was cancelled.", }; interface RequestOptions { method?: "GET" | "POST" | "DELETE"; body?: unknown; /** Admin routes need the private key; ingest routes take the public one. */ /** Only the write path carries a key; reads are scoped by session id. */ auth?: "ingest" | "none"; signal?: AbortSignal; timeoutMs?: number; baseUrl?: string; } async function request(path: string, options: RequestOptions = {}): Promise> { const { method = "GET", body, auth = "none", signal, timeoutMs = DEFAULT_TIMEOUT_MS, baseUrl, } = options; const settings = getSettings(); const headers: Record = {}; if (body !== undefined) headers["Content-Type"] = "application/json"; if (auth === "ingest") headers["X-API-Key"] = settings.ingestKey; // Sent on every request: it is what scopes reads, writes and deletes to this // browser's own records. const sessionId = getSessionId(); if (sessionId) headers["X-Session-Id"] = sessionId; const timeoutController = new AbortController(); const timer = setTimeout(() => timeoutController.abort(), timeoutMs); // Combine the caller's signal with the timeout so either can cancel. const signals = signal ? [signal, timeoutController.signal] : [timeoutController.signal]; const combined = typeof AbortSignal !== "undefined" && "any" in AbortSignal ? AbortSignal.any(signals) : timeoutController.signal; try { const response = await fetch(`${resolveBaseUrl(baseUrl)}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: combined, }); if (!response.ok) { let message = MESSAGES[errorKindFor(response.status)]; let requestId: string | undefined; try { const payload = await response.json(); if (typeof payload?.message === "string" && response.status !== 500) { message = payload.message; } if (typeof payload?.request_id === "string") requestId = payload.request_id; } catch { // A non-JSON error body is fine; the status-derived message stands. } return { ok: false, error: { kind: errorKindFor(response.status), status: response.status, message, requestId, }, }; } if (response.status === 204) { return { ok: true, data: undefined as T }; } return { ok: true, data: (await response.json()) as T }; } catch (error) { const aborted = error instanceof DOMException && error.name === "AbortError"; // The caller's own signal firing is a cancellation; ours firing is a timeout. const kind: ApiErrorKind = aborted ? (signal?.aborted ? "aborted" : "timeout") : "network"; return { ok: false, error: { kind, status: null, message: MESSAGES[kind] } }; } finally { clearTimeout(timer); } } function buildQuery(params: LogQueryParams): string { const search = new URLSearchParams(); if (params.dominant_emotion) search.set("dominant_emotion", params.dominant_emotion); if (params.start_date) search.set("start_date", params.start_date); if (params.end_date) search.set("end_date", params.end_date); if (params.q) search.set("q", params.q); if (params.skip !== undefined) search.set("skip", String(params.skip)); if (params.limit !== undefined) search.set("limit", String(params.limit)); const query = search.toString(); return query ? `?${query}` : ""; } export function createLog( input: EmotionLogInput, signal?: AbortSignal, ): Promise> { return request("/api/logs", { method: "POST", body: input, auth: "ingest", signal }); } export function createLogsBulk( items: EmotionLogInput[], signal?: AbortSignal, ): Promise> { return request("/api/logs/bulk", { method: "POST", body: { items }, auth: "ingest", signal, }); } export function listLogs( params: LogQueryParams = {}, signal?: AbortSignal, ): Promise> { return request(`/api/logs${buildQuery(params)}`, { signal }); } export function getLogStats( params: LogQueryParams = {}, signal?: AbortSignal, ): Promise> { return request(`/api/logs/stats${buildQuery(params)}`, { signal }); } export function deleteLog(id: number, signal?: AbortSignal): Promise> { return request(`/api/logs/${id}`, { method: "DELETE", signal }); } export function deleteAllLogs(signal?: AbortSignal): Promise> { return request("/api/logs?confirm=true", { method: "DELETE", signal }); } export function checkHealth( baseUrl?: string, signal?: AbortSignal, ): Promise> { return request("/api/health", { auth: "none", signal, timeoutMs: 5_000, baseUrl }); } /** Cheapest request that proves the API is reachable and answering. */ export function verifyConnection( baseUrl?: string, signal?: AbortSignal, ): Promise> { return request("/api/logs?limit=1", { signal, timeoutMs: 5_000, baseUrl }); }