// Thin typed fetch wrapper. Token is injected from localStorage (JWT auth — DECISIONS.md). import type { AdminCaseDetail, AdminCasesPage, CaseDog, AdminDog, AdminDogsPage, AdminOwnerDetail, AdminOwnersPage, BreedOption, BreedEstimateResult, DogDetailResult, PhotoSearchResult, TestMatchResult, Case, Dataset, DatasetDetail, DatasetDogsPage, EmbedAllResult, FoundReportResponse, Job, KnownDog, Match, MatchDatasetResult, Page, PurgeResult, ReclaimInfo, User, } from "./types"; const TOKEN_KEY = "pawtrace_token"; export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY); } export function setToken(t: string | null) { if (t) localStorage.setItem(TOKEN_KEY, t); else localStorage.removeItem(TOKEN_KEY); } export class ApiError extends Error { status: number; constructor(status: number, message: string) { super(message); this.status = status; } } async function request(path: string, opts: RequestInit = {}): Promise { const headers = new Headers(opts.headers); const token = getToken(); if (token) headers.set("Authorization", `Bearer ${token}`); if (!(opts.body instanceof FormData) && opts.body) { headers.set("Content-Type", "application/json"); } const res = await fetch(path, { ...opts, headers }); if (res.status === 204) return undefined as T; const data = await res.json().catch(() => ({})); if (!res.ok) { const msg = data?.error?.message || data?.detail || res.statusText; throw new ApiError(res.status, msg); } return data as T; } export const api = { // auth register: (body: { name: string; email: string; password: string; zip: string; phone?: string }) => request<{ access_token: string }>("/auth/register", { method: "POST", body: JSON.stringify(body) }), login: (body: { email: string; password: string }) => request<{ access_token: string }>("/auth/login", { method: "POST", body: JSON.stringify(body) }), me: () => request("/auth/me"), // dogs listDogs: () => request>("/dogs"), getDog: (id: number) => request(`/dogs/${id}`), createDog: (body: Partial) => request("/dogs", { method: "POST", body: JSON.stringify(body) }), uploadDogPhotos: (id: number, files: File[]) => { const fd = new FormData(); files.forEach((f) => fd.append("files", f)); return request(`/dogs/${id}/photos`, { method: "POST", body: fd }); }, markDogHome: (id: number) => request(`/dogs/${id}/mark-home`, { method: "POST" }), // cases createLost: (body: Record) => request("/cases/lost", { method: "POST", body: JSON.stringify(body) }), createFound: (fd: FormData) => request("/cases/found", { method: "POST", body: fd }), listCases: () => request>("/cases"), getCase: (id: number) => request(`/cases/${id}`), getCaseMatches: (id: number) => request(`/cases/${id}/matches`), getCaseDog: (id: number) => request(`/cases/${id}/dog`), widen: (id: number) => request(`/cases/${id}/widen`, { method: "POST" }), widenBreed: (id: number) => request(`/cases/${id}/widen-breed`, { method: "POST" }), rematchCase: (id: number) => request(`/cases/${id}/rematch`, { method: "POST" }), updateCase: (id: number, body: { notes?: string; close?: boolean }) => request(`/cases/${id}`, { method: "PATCH", body: JSON.stringify(body) }), // matches confirmMatch: (id: number) => request(`/matches/${id}/confirm`, { method: "POST" }), rejectMatch: (id: number) => request(`/matches/${id}/reject`, { method: "POST" }), reconsiderMatch: (id: number) => request(`/matches/${id}/reconsider`, { method: "POST" }), reclaimInfo: (id: number) => request(`/matches/${id}/reclaim`), // geo shelters: (zip?: string) => request<{ zip: string | null; shelters: { name: string; detail: string }[] }>( `/geo/nearby-shelters${zip ? `?zip=${encodeURIComponent(zip)}` : ""}` ), // public runtime config (read on load to decide which UI to render + haystack size for the UI) getConfig: () => request<{ demo_mode: boolean; haystack_size: number }>("/config"), // public photo search (no auth). Accepts 1-6 images of the same dog — the server scores every // query image against every candidate image and keeps the best pair, so more angles only help. // pool: "found" searches found/unknown dogs (I lost my dog); "lost" searches known/lost dogs (I // found a dog and want its owner). searchByPhoto: (files: File[], zip?: string, pool: "found" | "lost" = "found", topK = 12) => { const fd = new FormData(); files.forEach((f) => fd.append("files", f)); if (zip) fd.append("zip", zip); fd.append("top_k", String(topK)); fd.append("pool", pool); return request("/search/by-photo", { method: "POST", body: fd }); }, // Breed estimation over 1-6 images of the same dog. The server averages the per-label scores // across every image — one photo alone can easily flip the top breed. estimateBreed: (files: File[], topN = 5) => { const fd = new FormData(); files.forEach((f) => fd.append("files", f)); fd.append("top_n", String(topN)); return request("/search/breed", { method: "POST", body: fd }); }, // admin: datasets / data loading listAllDogs: ( kind: "all" | "known" | "unknown" = "all", limit = 30, offset = 0, opts: { breed?: string; breedK?: number; sort?: "newest" | "oldest"; zip?: string; addedFrom?: string; addedTo?: string; } = {} ) => { const p = new URLSearchParams({ kind, limit: String(limit), offset: String(offset) }); if (opts.breed) { p.set("breed", opts.breed); p.set("breed_k", String(opts.breedK ?? 10)); } if (opts.sort) p.set("sort", opts.sort); if (opts.zip) p.set("zip", opts.zip); if (opts.addedFrom) p.set("added_from", opts.addedFrom); if (opts.addedTo) p.set("added_to", opts.addedTo); return request(`/admin/dogs?${p}`); }, listBreeds: () => request<{ model: [string, string] | null; breeds: BreedOption[] }>("/admin/breeds"), listAllCases: (kind?: "lost" | "found", status?: string, limit = 30, offset = 0) => { const q = new URLSearchParams({ limit: String(limit), offset: String(offset) }); if (kind) q.set("kind", kind); if (status) q.set("status", status); return request(`/admin/cases?${q}`); }, listOwners: (limit = 30, offset = 0, q?: string) => { const p = new URLSearchParams({ limit: String(limit), offset: String(offset) }); if (q) p.set("q", q); return request(`/admin/owners?${p}`); }, getOwnerDetail: (id: number) => request(`/admin/owners/${id}`), resetDemo: () => request<{ matches_reset: number; cases_reopened: number; known_relost: number; found_reset: number }>( "/admin/reset-demo", { method: "POST" }, ), getCaseDetail: (caseId: number) => request(`/admin/cases/${caseId}`), runCaseMatch: (caseId: number) => request(`/admin/cases/${caseId}/run-match`, { method: "POST" }), deleteCase: (caseId: number) => request<{ deleted: Record }>(`/admin/cases/${caseId}`, { method: "DELETE" }), deleteDog: (kind: "known" | "unknown", dogId: number) => request<{ deleted: Record }>(`/admin/dogs/${kind}/${dogId}`, { method: "DELETE" }), setDogStatus: (kind: "known" | "unknown", dogId: number, status: string) => request<{ profile: AdminDog }>(`/admin/dogs/${kind}/${dogId}/status`, { method: "POST", body: JSON.stringify({ status }), }), deletePerson: (userId: number) => request<{ deleted: Record }>(`/admin/owners/${userId}`, { method: "DELETE" }), testMatch: (kind: "known" | "unknown", dogId: number, topK = 10, applyBreedGate = false) => request( `/admin/test-match?kind=${kind}&dog_id=${dogId}&top_k=${topK}&apply_breed_gate=${applyBreedGate}` ), getDogDetail: (kind: "known" | "unknown", dogId: number) => request(`/admin/dog/${kind}/${dogId}`), listDatasets: () => request("/admin/datasets"), getDataset: (id: number) => request(`/admin/datasets/${id}`), listDatasetDogs: (id: number, limit = 25, offset = 0) => request(`/admin/datasets/${id}/dogs?limit=${limit}&offset=${offset}`), deleteDataset: (id: number) => request(`/admin/datasets/${id}`, { method: "DELETE" }), embedAll: (id: number) => request(`/admin/datasets/${id}/embed-all`, { method: "POST" }), startEmbedJob: (id: number) => request<{ job_id: string; status: string }>(`/admin/datasets/${id}/embed-all-job`, { method: "POST", }), matchDataset: (id: number, candidateDatasetId?: number) => request( `/admin/datasets/${id}/match${candidateDatasetId ? `?candidate_dataset_id=${candidateDatasetId}` : ""}`, { method: "POST" } ), startLoad: (fd: FormData) => request<{ job_id: string; status: string }>("/admin/datasets/load", { method: "POST", body: fd }), getJob: (jobId: string) => request(`/admin/jobs/${jobId}`), };