import type { AdverseImpactReport, CandidateIntelligence, CandidateListView, CommandCenterView, ExternalCandidate, FederalAward, MissionCheckModeAResult, MissionCheckModeBResult, OpportunityFilters, OpportunityFitView, RankedCandidateView, RequirementListView, SamOpportunity, SaveCandidateRequest, ScreenView, SlateResult, WageBaseline, } from './types' // opportunityQuery turns the filter object into a query string. Multi valued filters are repeated // params (?naics=a&naics=b); the backend also accepts comma lists. function opportunityQuery(f: OpportunityFilters): string { const p = new URLSearchParams() f.naics?.forEach((v) => p.append('naics', v)) f.set_aside?.forEach((v) => p.append('set_aside', v)) f.state?.forEach((v) => p.append('state', v)) f.department?.forEach((v) => p.append('department', v)) f.sub_agency?.forEach((v) => p.append('sub_agency', v)) if (f.q) p.set('q', f.q) if (f.active_only === false) p.set('active_only', 'false') if (f.limit) p.set('limit', String(f.limit)) const s = p.toString() return s ? `?${s}` : '' } export class ApiError extends Error { status: number constructor(status: number, message: string) { super(message) this.status = status } } // csrfToken is the signed double submit token issued by GET /auth/csrf. The companion __Host-mm_csrf // cookie is set by that response; the __Host-mm_session cookie rides along via credentials include, so // the validated session is the tenant source, not a client header. let csrfToken: string | null = null let csrfInflight: Promise | null = null async function ensureCsrf(): Promise { if (csrfToken) return csrfToken // De-duplicate concurrent mints behind one shared fetch. Without this, two unsafe requests racing on a // null token each hit GET /auth/csrf, the second response overwrites the __Host-mm_csrf cookie, and the // first request's header no longer matches its cookie, so the double submit check 403s. One mint per // token lifetime keeps the cookie and every header in agreement. if (!csrfInflight) { csrfInflight = fetch('/api/auth/csrf', { credentials: 'include' }) .then(async (res) => { if (res.ok) { const body = (await res.json()) as { csrf_token?: string } csrfToken = body.csrf_token ?? null } return csrfToken ?? '' }) .finally(() => { csrfInflight = null }) } return csrfInflight } async function doFetch(path: string, init: RequestInit, method: string): Promise { const headers: Record = { 'Content-Type': 'application/json', ...((init.headers as Record) ?? {}), } if (method !== 'GET' && method !== 'HEAD') { headers['X-CSRF-Token'] = await ensureCsrf() } // credentials include sends the __Host-mm_session cookie; the validated session is the tenant source. return fetch(`/api${path}`, { ...init, credentials: 'include', headers }) } async function rawRequest(path: string, init?: RequestInit): Promise { const method = (init?.method ?? 'GET').toUpperCase() let res = await doFetch(path, init ?? {}, method) // A stale or rotated CSRF token returns 403; refresh once and retry the unsafe verb. if (res.status === 403 && method !== 'GET' && method !== 'HEAD') { csrfToken = null res = await doFetch(path, init ?? {}, method) } if (!res.ok) { let detail = res.statusText try { const body = (await res.clone().json()) as { error?: string } if (body.error) detail = body.error } catch { /* ignore non-JSON error bodies */ } throw new ApiError(res.status, detail) } return res } export async function request(path: string, init?: RequestInit): Promise { const res = await rawRequest(path, init) return (await res.json()) as T } async function requestText(path: string): Promise { const res = await rawRequest(path) return res.text() } export const api = { candidates: () => request('/candidates?limit=200'), candidateIntelligence: (limit = 250) => request(`/candidates/intelligence?limit=${limit}`), // Fetch one candidate or requirement by id so a deep linked evaluation works even when the row is // not inside the first list page; the list scan would silently render a blank evaluation otherwise. candidate: (id: string) => request(`/candidates/${encodeURIComponent(id)}`), requirements: () => request('/requirements?limit=200'), requirement: (id: string) => request(`/requirements/${encodeURIComponent(id)}`), screen: (candidate_id: string, requirement_id: string) => request('/screen', { method: 'POST', body: JSON.stringify({ candidate_id, requirement_id }), }), generateSlate: (requirement_id: string, name: string) => request('/slates/generate', { method: 'POST', body: JSON.stringify({ requirement_id, name }), }), opportunities: (candidateId: string) => request(`/candidates/${candidateId}/opportunities?limit=10`), rankCandidates: (requirementId: string) => request(`/requirements/${requirementId}/candidates`), commandCenter: () => request('/command-center'), // Cached SAM.gov opportunities (the requirement feed). The frontend NEVER calls SAM live. samOpportunities: (f: OpportunityFilters = {}) => request(`/opportunities${opportunityQuery(f)}`), samOpportunity: (noticeId: string) => request(`/opportunities/${encodeURIComponent(noticeId)}`), // USASpending awards (cache only): incumbents by NAICS and Galapagos own past performance. incumbents: (naics: string) => request(`/awards?naics=${encodeURIComponent(naics)}&limit=6`), galapagosAwards: () => request('/awards/galapagos?limit=8'), // BLS OEWS regional wage baseline (cache only). wages: () => request('/wages'), // Externally sourced candidates (People Data Labs) plus the delete path. externalCandidates: () => request('/sourcing/candidates'), deleteExternalCandidate: (id: string) => request<{ status: string }>(`/sourcing/candidates/${encodeURIComponent(id)}`, { method: 'DELETE' }), // AEDT: the human in the loop decision on a slate member (approved or rejected). decideSlateMember: (slateId: string, candidateId: string, decision: 'approved' | 'rejected') => request<{ status: string }>( `/slates/${encodeURIComponent(slateId)}/members/${encodeURIComponent(candidateId)}/decision`, { method: 'PATCH', body: JSON.stringify({ decision }) }, ), // Compliance: the adverse impact report (compliance role only) and its publication ready export. adverseImpact: () => request('/compliance/adverse-impact'), adverseImpactExport: () => requestText('/compliance/adverse-impact/export'), // Mission Check (W1): the resume is parsed to text in the browser; only the text crosses the wire. // Mode A scores it against every open opportunity, Mode B against one pasted job description. missionCheckOpportunities: (resume_text: string, limit = 10) => request('/mission-check/opportunities', { method: 'POST', body: JSON.stringify({ resume_text, limit }), }), missionCheckJD: (resume_text: string, job_description_text: string) => request('/mission-check/screen', { method: 'POST', body: JSON.stringify({ resume_text, job_description_text }), }), // Save Candidate: persist a reviewed Mission Check result as an audited candidate (recruiter capability). saveCandidate: (body: SaveCandidateRequest) => request<{ id: string }>('/candidates', { method: 'POST', body: JSON.stringify(body) }), }