import type { AdverseImpactReport, CurationProposalView, CandidateListView, ExternalCandidate, FederalAward, MissionCheckModeAResult, MissionCheckModeBResult, OpportunityFilters, OpportunityFitView, RankedCandidateView, RequirementListView, SamOpportunity, SaveCandidateRequest, ScreenView, 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) { throw new Error(`csrf token request failed: ${res.status}`) } const body = (await res.json()) as { csrf_token?: string } const token = body.csrf_token if (!token) { throw new Error('csrf token request returned an empty token') } csrfToken = token return token }) .finally(() => { // Clear the in flight marker on success and failure so a failed mint is never cached and a // later request can retry rather than sending an empty token into a guaranteed 403 loop. csrfInflight = null }) } return csrfInflight } // normalizeHeaders flattens any HeadersInit form (a plain object, a Headers instance, or a tuple array) // to a plain record so the spread below is correct for every caller, not just plain objects. function normalizeHeaders(h: HeadersInit | undefined): Record { if (!h) return {} if (h instanceof Headers) return Object.fromEntries(h.entries()) if (Array.isArray(h)) return Object.fromEntries(h) return { ...h } } async function doFetch(path: string, init: RequestInit, method: string): Promise { const headers: Record = { 'Content-Type': 'application/json', ...normalizeHeaders(init.headers), } 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'), // 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 }), }), opportunities: (candidateId: string) => request(`/candidates/${candidateId}/opportunities?limit=10`), rankCandidates: (requirementId: string) => request(`/requirements/${requirementId}/candidates`), // 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' }), // Compliance: the adverse impact report (compliance role only) and its publication ready export. adverseImpact: () => request('/compliance/adverse-impact'), adverseImpactExport: () => requestText('/compliance/adverse-impact/export'), // WS8 curation: the pending teaching proposals and the two step human gate (compliance or operator role). // Approve stages an inert ontology edge; Verify (a separate action) activates it for future scores. curationProposals: () => request('/compliance/curation/proposals'), approveCurationProposal: (id: string) => request(`/compliance/curation/proposals/${encodeURIComponent(id)}/approve`, { method: 'POST' }), verifyCurationProposal: (id: string) => request(`/compliance/curation/proposals/${encodeURIComponent(id)}/verify`, { method: 'POST' }), // 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) }), }