'use client'; /** * Browser-side fetches: same origin `/api/v1/*` (Next rewrite → FastAPI). Never import the server `api.ts` in client code. * Owner and admin calls attach their token headers (`lib/owner.ts`, `lib/admin.ts`). */ import type { Alert, AlertCondition, AlertDelivery, AdminCompany, AdminConnector, AdminCosts, AdminFailure, AdminLlmJob, AdminOverview, AdminQuality, AdminQueueItem, AdminReview, AdminSensor, AskPayload, CompanyCard, Event, EventDetail, Page, SearchPayload, Suggestion, WatchlistPayload, } from './types'; export class ClientApiError extends Error { readonly status: number; readonly detail: string | null; constructor(status: number, path: string, detail: string | null = null) { super(detail ?? `API ${status} on ${path}`); this.name = 'ClientApiError'; this.status = status; this.detail = detail; } } type Init = { method?: string; body?: unknown; headers?: Record; signal?: AbortSignal }; async function call(path: string, init: Init = {}): Promise { const headers: Record = { accept: 'application/json', ...(init.headers ?? {}) }; if (init.body !== undefined) headers['content-type'] = 'application/json'; const res = await fetch(`/api/v1${path}`, { method: init.method ?? 'GET', headers, body: init.body === undefined ? undefined : JSON.stringify(init.body), signal: init.signal, cache: 'no-store' }); if (!res.ok) { let detail: string | null = null; try { const b = (await res.json()) as { detail?: unknown }; detail = typeof b.detail === 'string' ? b.detail : null; } catch { /* ignore */ } throw new ClientApiError(res.status, path, detail); } if (res.status === 204) return undefined as T; return (await res.json()) as T; } const enc = encodeURIComponent; export function buildQs(q: Record): string { const p = new URLSearchParams(); for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== '') p.set(k, typeof v === 'boolean' ? (v ? '1' : '0') : String(v)); const s = p.toString(); return s ? `?${s}` : ''; } export const clientApi = { suggest: (q: string, signal?: AbortSignal) => call<{ items: Suggestion[] }>(`/search/suggest?q=${enc(q)}`, { signal }), search: (q: string, limit = 8, signal?: AbortSignal) => call(`/search?q=${enc(q)}&limit=${limit}`, { signal }), ask: (q: string, signal?: AbortSignal) => call(`/ask?q=${enc(q)}`, { signal }), live: (q: Record, signal?: AbortSignal) => call<{ items: Event[] } | Event[]>(`/live${buildQs(q)}`, { signal }), event: (id: string, signal?: AbortSignal) => call(`/events/${enc(id)}`, { signal }), companies: (q: Record, signal?: AbortSignal) => call>(`/companies${buildQs(q)}`, { signal }), }; /** Watchlist + alerts with the owner token. */ export function ownerApi(token: string) { const h = { 'X-CA-Owner-Token': token }; return { watchlist: (signal?: AbortSignal) => call('/watchlist', { headers: h, signal }), watch: (slug: string) => call('/watchlist', { method: 'POST', body: { company: slug }, headers: h }), unwatch: (slug: string) => call(`/watchlist/${enc(slug)}`, { method: 'DELETE', headers: h }), alerts: (signal?: AbortSignal) => call<{ items: Alert[] }>('/alerts', { headers: h, signal }), createAlert: (body: { name: string; company?: string; condition: AlertCondition; channel: 'web' | 'webhook'; target?: string }) => call('/alerts', { method: 'POST', body, headers: h }), deleteAlert: (id: string) => call(`/alerts/${enc(id)}`, { method: 'DELETE', headers: h }), deliveries: (limit = 50, signal?: AbortSignal) => call<{ items: AlertDelivery[] }>(`/alerts/deliveries?limit=${limit}`, { headers: h, signal }), }; } /** Admin console with the admin token. */ export function adminApi(token: string) { const h = { 'X-CA-Admin-Token': token }; const g = (path: string, signal?: AbortSignal) => call(path, { headers: h, signal }); const post = (path: string, body?: unknown) => call(path, { method: 'POST', body: body ?? {}, headers: h }); return { overview: (signal?: AbortSignal) => g('/admin/overview', signal), connectors: (signal?: AbortSignal) => g<{ items: AdminConnector[] }>('/admin/connectors', signal), sensors: (q: Record, signal?: AbortSignal) => g>(`/admin/sensors${buildQs(q)}`, signal), sensorAction: (id: string, action: string, body?: unknown) => post(`/admin/sensors/${enc(id)}/${action}`, body), companies: (q: Record, signal?: AbortSignal) => g>(`/admin/companies${buildQs(q)}`, signal), createCompany: (body: { website: string; display_name?: string; country?: string; industries?: string[] }) => post('/admin/companies', body), rediscover: (slug: string) => post(`/admin/companies/${enc(slug)}/rediscover`), failures: (q: Record, signal?: AbortSignal) => g>(`/admin/failures${buildQs(q)}`, signal), queue: (q: Record, signal?: AbortSignal) => g<{ items: AdminQueueItem[]; counts?: Record } | Page>(`/admin/queue${buildQs(q)}`, signal), requeueDead: () => post('/admin/queue/requeue-dead'), llm: (q: Record, signal?: AbortSignal) => g>(`/admin/llm${buildQs(q)}`, signal), reviews: (q: Record, signal?: AbortSignal) => g<{ items: AdminReview[] } | Page>(`/admin/reviews${buildQs(q)}`, signal), resolveReview: (id: string, resolution: 'accepted' | 'rejected', note?: string) => post(`/admin/reviews/${enc(id)}`, { resolution, note }), retractEvent: (id: string, reason: string) => post(`/admin/events/${enc(id)}/retract`, { reason }), restoreEvent: (id: string) => post(`/admin/events/${enc(id)}/restore`), quality: (signal?: AbortSignal) => g('/admin/quality', signal), costs: (days = 30, signal?: AbortSignal) => g(`/admin/costs?days=${days}`, signal), clearCache: () => post('/admin/cache/clear'), }; }