'use client'; import { useUIStore } from '@/stores/ui-store'; export interface ApiFetchOptions extends Omit { body?: unknown; } /** In-memory cache: slug -> id, populated lazily by `resolvePortIdFromSlug`. * Avoids re-fetching `/api/v1/admin/ports` on every request when the Zustand * store hasn't hydrated yet (fresh browser context, e2e tests, hard reload). */ const slugToIdCache = new Map(); /** Dedupe in-flight admin/ports lookups so a stampede of parallel apiFetch * calls (typical on dashboard mount) collapses into a single network round- * trip instead of N. */ let inFlightPortsLookup: Promise | null> | null = null; async function resolvePortIdFromSlug(slug: string): Promise { const cached = slugToIdCache.get(slug); if (cached) return cached; if (!inFlightPortsLookup) { inFlightPortsLookup = (async () => { try { const res = await fetch('/api/v1/admin/ports', { credentials: 'include' }); if (!res.ok) return null; const body = (await res.json()) as { data?: Array<{ id: string; slug: string }> }; return body.data ?? null; } catch { return null; } })().finally(() => { inFlightPortsLookup = null; }); } const ports = await inFlightPortsLookup; const port = ports?.find((p) => p.slug === slug); if (!port) return null; slugToIdCache.set(slug, port.id); return port.id; } /** * Client-side fetch wrapper that attaches the `X-Port-Id` header to * every request. * * multi-port-auditor C1: the URL slug is authoritative — Zustand * is a cache that lags by one render after `PortProvider`'s reconcile * effect commits. The previous Zustand-first lookup caused first-load * queries on a freshly-navigated port to fire with the PRIOR port's * id and render cross-port data inside the new shell. Now we resolve * the slug from `window.location.pathname` first and fall back to * Zustand only when the URL doesn't carry a port slug (e.g. /dashboard * / non-portSlug routes). */ export async function apiFetch(url: string, opts: ApiFetchOptions = {}): Promise { let portId: string | null = null; if (typeof window !== 'undefined') { const slug = window.location.pathname.split('/').filter(Boolean)[0]; if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api' && slug !== 'dashboard') { portId = await resolvePortIdFromSlug(slug); } } // Fall back to the Zustand cache when the URL didn't yield a port — // e.g. global routes (/dashboard) where the rep hasn't picked a port // yet but a previous session set one. if (!portId) { portId = useUIStore.getState().currentPortId; } const headers = new Headers(opts.headers); if (portId) { headers.set('X-Port-Id', portId); } if (opts.body !== undefined && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } const res = await fetch(url, { ...opts, headers, credentials: 'include', body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, }); if (!res.ok) { // error-ux-auditor C3: reverse-proxy 502/504 pages deliver HTML, not // JSON. The previous code silently degraded to // `{error: res.statusText}` which surfaced "Bad Gateway" with no // requestId and no copy-pasteable correlation handle. Detect the // proxy-error shape (5xx + JSON parse fail) and synthesize a // client-side correlation id so the toast still has *something* the // user can quote to support. const error = (await res.json().catch(() => null)) as { error?: string; message?: string; code?: string; details?: unknown; requestId?: string; retryAfter?: number; } | null; const upstreamRequestId = res.headers.get('x-request-id'); if (error === null) { const isProxyFailure = res.status >= 500; // Short, copy-pasteable client-side handle so support can grep // the front-end logs even when the proxy never reached our app. const synthId = upstreamRequestId ?? `client-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; throw new ApiError({ message: isProxyFailure ? 'The server is unreachable. Please try again.' : res.statusText || 'Request failed', status: res.status, code: isProxyFailure ? 'UPSTREAM_UNREACHABLE' : null, details: null, requestId: synthId, retryAfter: null, }); } const requestId = error.requestId ?? upstreamRequestId ?? null; throw new ApiError({ message: error.error ?? error.message ?? 'Request failed', status: res.status, code: error.code ?? null, details: error.details ?? null, requestId, retryAfter: typeof error.retryAfter === 'number' ? error.retryAfter : null, }); } if (res.status === 204) return undefined as T; return res.json() as Promise; } /** * Structured client-side error thrown by `apiFetch`. Carries the stable * fields a toast / error boundary needs to render a useful message: * * - `message`: plain-text, ready to show to the user * - `code`: stable error code from `src/lib/error-codes.ts` * - `requestId`: paste this to support to find the row in * `/admin/errors/` * * Mutations should use the `toastError(err)` helper rather than reading * these fields directly — that keeps the toast format consistent. */ export class ApiError extends Error { status: number; code: string | null; details: unknown; requestId: string | null; retryAfter: number | null; constructor(args: { message: string; status: number; code: string | null; details: unknown; requestId: string | null; retryAfter: number | null; }) { super(args.message); this.name = 'ApiError'; this.status = args.status; this.code = args.code; this.details = args.details; this.requestId = args.requestId; this.retryAfter = args.retryAfter; } }