/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */ import type { AuditEntry, Batch, BatchStage, DemographyRecord, DemographyRun, InsuranceMasterRow, LastRun, ManagedUser, Project, ResultRow, ReviewProgress, ReviewRow, RoiMonth, RoiRun, RoiTotals, Run, } from './types' /** * Every call goes to a same-origin /api path. In development Vite proxies that * to the FastAPI port, in production both are served from one origin — so no * base URL is configured anywhere and there is nothing to get wrong per * environment. */ /** * FastAPI's `detail` in readable form. * * Most of this API refuses with a plain sentence, and for those this is the * identity function. A 422 is the exception: validation errors arrive as an * array of `{loc, msg}` objects, and handing that to `new Error()` produced * the literal string "[object Object]" — which is how a missing request field * spent a while looking like an unexplained failure. */ function errorText(detail: unknown): string | null { if (typeof detail === 'string') return detail if (Array.isArray(detail)) { const parts = detail .map((d) => { if (typeof d === 'string') return d const msg = (d as { msg?: string })?.msg // `loc` is ["body", "role"]; the last hop is the field, and naming it // is the difference between "field required" and knowing which one. const loc = (d as { loc?: unknown[] })?.loc const field = Array.isArray(loc) ? String(loc[loc.length - 1]) : null return msg ? (field ? `${field}: ${msg}` : msg) : null }) .filter(Boolean) return parts.length ? parts.join('; ') : null } if (detail && typeof detail === 'object') { const msg = (detail as { msg?: string; message?: string }) return msg.msg ?? msg.message ?? null } return null } // The review screen's two reads give up after this long. fetch has no timeout // of its own, and a response whose body stalls never rejects, so a stalled read // left "Loading review queue…" up forever — react-query never got the failure // its retry (QueryClient retry: 1) needs. Scoped to those two reads on purpose: // they are a handful of queries each, while /api/batches and /api/roi scale with // the data and could legitimately pass 20s, where a cap would turn "slow" into a // false empty board. Writes are never capped — retrying a write is not safe. const REVIEW_READ_TIMEOUT_MS = 20_000 const reviewRead = () => ({ signal: AbortSignal.timeout(REVIEW_READ_TIMEOUT_MS) }) async function req(path: string, init?: RequestInit): Promise { const res = await fetch(path, { ...init, // The session lives in a cookie, and a cross-origin dev request drops // cookies unless asked to send them. Without this every call is anonymous // and the whole app 401s behind the Vite proxy. credentials: 'include', headers: init?.body instanceof FormData ? init.headers : { 'Content-Type': 'application/json', ...init?.headers }, }) if (!res.ok) { // The API says WHY it refused — an approve blocked by unverified fields // returns 409 with the count. Surfacing that beats a generic failure. const body = await res.json().catch(() => null) throw new Error(errorText(body?.detail) ?? `${res.status} ${res.statusText}`) } return res.json() as Promise } export const api = { // ── batches ─────────────────────────────────────────────────────────────── /** * Upload a stack of files as ONE batch. Nothing starts extracting yet — the * batch has its own Run button, so a reviewer who just dropped twenty files * can look at the list before spending twenty documents' worth of quota. */ createBatch: (files: File[], model = 'gemini-3.5-flash', project = 'ecw') => { const body = new FormData() for (const f of files) body.append('files', f) // `project` is which board received the drop. Nothing has detected a // template yet, and the board a batch appears on is derived from that, so // without it a Example Client upload surfaces under ECW and reads as a failed // upload on the Tebra board. return req(`/api/batches?${new URLSearchParams({ model, project })}`, { method: 'POST', body, }) }, /** Gather loose documents into a new batch — the only route by which a * document not uploaded as part of a batch can reach the automation board. */ batchFromRuns: (jobIds: string[], stage: BatchStage = 'automation') => req('/api/batches/from-runs', { method: 'POST', body: JSON.stringify({ job_ids: jobIds, stage }), }), listBatches: () => req<{ batches: Batch[] }>('/api/batches'), getBatch: (batchId: string) => req(`/api/batches/${batchId}`), runBatch: (batchId: string, model?: string) => req(`/api/batches/${batchId}/run`, { method: 'POST', body: JSON.stringify({ model: model ?? null }), }), /** Tick or untick a batch as cleared to run. Admin and above. */ setRunApproval: (batchId: string, approved: boolean) => req(`/api/batches/${batchId}/run-approval`, { method: 'PUT', body: JSON.stringify({ approved }), }), /** Every ticket's latest result, in reading order: the Download summary's * rows (ADR 0003). */ batchResults: (batchId: string) => // counts: the header's three groups, one per encounter, as the Excel writes them. req<{ rows: ResultRow[]; counts: Record; last_run: LastRun | null }>( `/api/batches/${batchId}/results`, ), /** Sign off every field of every document in the batch and clear it to * run, so it can be posted without opening each one. */ approveAll: (batchId: string) => req(`/api/batches/${batchId}/approve-all`, { method: 'POST' }), setBatchStage: (batchId: string, stage: BatchStage) => req(`/api/batches/${batchId}/stage`, { method: 'POST', body: JSON.stringify({ stage }), }), /** 'precheck' reads every patient out of Tebra and posts nothing; * 'post' creates the encounters. Defaults to the harmless one. */ runAutomation: ( batchId: string, mode: 'precheck' | 'post' = 'precheck', ticket?: string, allowDuplicate = false, ) => req( `/api/batches/${batchId}/run-automation`, { method: 'POST', body: JSON.stringify({ mode, ticket, allow_duplicate: allowDuplicate }), }, ), // ── runs ────────────────────────────────────────────────────────────────── startRun: (file: File, model = 'gemini-3.5-flash', thinking = false) => { const body = new FormData() body.append('file', file) const q = new URLSearchParams({ model, thinking: String(thinking) }) return req<{ job_id: string; status: string }>(`/api/runs?${q}`, { method: 'POST', body, }) }, listRuns: () => req<{ runs: Run[] }>('/api/runs'), rerunMissingPages: (jobId: string) => req<{ job_id: string; status: string }>(`/api/runs/${jobId}/missing-pages`, { method: 'POST', }), retryRun: (jobId: string, model?: string) => req<{ job_id: string; status: string; model: string }>( `/api/runs/${jobId}/retry`, { method: 'POST', body: JSON.stringify({ model: model ?? null }) }, ), getRun: (jobId: string) => req(`/api/runs/${jobId}`), deleteRun: (jobId: string) => req<{ job_id: string; deleted: boolean }>( `/api/runs/${jobId}`, { method: 'DELETE' }, ), getAllFields: (jobId: string) => req<{ rows: ReviewRow[] }>(`/api/runs/${jobId}/all-fields`, reviewRead()), getReview: (jobId: string, opts?: { band?: string; verified?: boolean }) => { const q = new URLSearchParams() if (opts?.band) q.set('band', opts.band) if (opts?.verified !== undefined) q.set('verified', String(opts.verified)) const qs = q.toString() return req<{ rows: ReviewRow[]; progress: ReviewProgress }>( `/api/runs/${jobId}/review${qs ? `?${qs}` : ''}`, reviewRead(), ) }, patchRow: (rowId: number, body: { corrected_value?: string | null verified: boolean actor?: string }) => req(`/api/review/${rowId}`, { method: 'PATCH', body: JSON.stringify(body), }), /** * Correct any field, including one that was never flagged. * * Green means the print was legible, not that the value is right, so a * cleanly-read field still has to be overridable. Those fields have no * review row until somebody edits one, so they are addressed by the cell * they occupy rather than by row_id. */ editField: (jobId: string, body: { page: number | null patient: string | null field: string value: string | null band: string score: number | null corrected_value: string | null verified: boolean actor?: string line_index?: number }) => req(`/api/runs/${jobId}/fields`, { method: 'POST', body: JSON.stringify(body), }), pdfInfo: (jobId: string) => req<{ pages: number; name: string }>(`/api/runs/${jobId}/pdf-info`), pageImage: (jobId: string, page: number, dpi: number) => `/api/runs/${jobId}/page/${page}?dpi=${dpi}`, /** Sign off every remaining flagged field as extracted, in one deliberate * decision. Satisfies the approve gate rather than bypassing it, and is * audited as `bulk_verified` so it stays distinguishable afterwards. */ verifyRemaining: (jobId: string, actor?: string) => req<{ verified: number; progress: ReviewProgress }>( `/api/runs/${jobId}/verify-remaining`, { method: 'POST', body: JSON.stringify({ actor }) }, ), approve: (jobId: string, actor?: string) => req<{ ok: boolean; approved: number }>(`/api/runs/${jobId}/approve`, { method: 'POST', body: JSON.stringify({ actor }), }), /** The cost ledger. Measured figures only — the saving is the client's own * arithmetic over its own editable rate, and is never returned by the API. */ roi: () => req<{ runs: RoiRun[]; totals: RoiTotals; monthly: RoiMonth[] }>('/api/roi'), /** The same shape for demography runs: one total per run, no field figures. */ demographyRoi: () => req<{ runs: RoiRun[]; totals: RoiTotals; monthly: RoiMonth[] }>('/api/demography/roi'), // ── people and access ───────────────────────────────────────────────────── /** `can_grant` is what the SERVER says this caller may grant. The client * renders exactly those options rather than re-deriving the hierarchy. * `project` names which screen is asking; a scoped admin's own project * always wins server-side regardless of what is passed here. */ /** `'all'` asks for the merged, both-projects list — the server only * honours that for a super_admin or an admin of BOTH projects; anyone * else gets their own single project's list regardless of what is sent * here, so passing 'all' cannot widen a scoped admin's view. */ listUsers: (project: Project | 'all') => req<{ users: ManagedUser[]; can_grant: string[] }>( `/api/users?project=${encodeURIComponent(project)}`, ), /** Staff directory autocomplete. Returns [] rather than failing when the * directory is unreachable, so the field stays usable by hand. */ searchEmployees: (q: string) => req<{ employees: { username: string; real_name: string | null }[] }>( `/api/users/search?q=${encodeURIComponent(q)}`, ), /** `project` is required by the server when `role` is 'admin', and ignored * otherwise — passed unconditionally here so the caller never has to know * which roles care. */ /** `realName` is set only for a brand-new grant made through the directory * picker; the server only ever uses it to fill a blank name, never to * overwrite one it already has. */ assignRole: (username: string, role: string, project?: Project, realName?: string | null) => req(`/api/users/${encodeURIComponent(username)}/role`, { method: 'PUT', body: JSON.stringify({ role, project, real_name: realName }), }), /** Removes admin standing in ONE project only. A person still admin of the * other project keeps that access — it does not stop them signing in * either way; they return as an operator (of this project, at least). */ removeAccess: (username: string, project: Project) => req<{ ok: boolean; removed: string }>( `/api/users/${encodeURIComponent(username)}?project=${encodeURIComponent(project)}`, { method: 'DELETE' }, ), audit: (jobId?: string) => req<{ entries: AuditEntry[] }>( `/api/audit${jobId ? `?job_id=${jobId}` : ''}`, ), // ── demography ──────────────────────────────────────────────────────────── /** Upload one facesheet. Unlike a batch, this starts immediately: OCR spends * CPU rather than API quota, so there is nothing for an approval to guard. */ startDemographyRun: (file: File) => { const body = new FormData() body.append('file', file) return req<{ job_id: string; status: string }>('/api/demography/runs', { method: 'POST', body, }) }, /** `stage` omitted lists every run; passed, it filters server-side — * a NULL stage counts as 'extraction' there, same as an unmoved batch. */ listDemographyRuns: (stage?: 'extraction' | 'automation') => req<{ runs: DemographyRun[] }>( stage ? `/api/demography/runs?stage=${stage}` : '/api/demography/runs', ), patchDemographyField: (jobId: string, rowId: number, correctedValue: string | null) => req<{ row_id: number; corrected_value: string | null }>( `/api/demography/runs/${jobId}/fields/${rowId}`, { method: 'PATCH', body: JSON.stringify({ corrected_value: correctedValue }) }, ), setDemographyStage: (jobId: string, stage: 'extraction' | 'automation') => req(`/api/demography/runs/${jobId}/stage`, { method: 'POST', body: JSON.stringify({ stage }), }), demographyRecords: (jobId: string) => req<{ run: DemographyRun; records: DemographyRecord[] }>( `/api/demography/runs/${jobId}/records`, ), /** Every Insurance Master row, for the insurance finder. */ insuranceMaster: () => req<{ insurances: InsuranceMasterRow[] }>('/api/demography/insurance-master'), deleteDemographyRun: (jobId: string) => req<{ job_id: string; deleted: boolean }>( `/api/demography/runs/${jobId}`, { method: 'DELETE' }, ), /** A URL string, not a promise — used as an so the browser's own * download handling takes over, the same way `pageImage` is used as a src. */ demographyExportUrl: (jobId: string) => `/api/demography/runs/${jobId}/export.xlsx`, /** The uploaded facesheet, shown in the browser's own PDF viewer. */ demographyPdfUrl: (jobId: string) => `/api/demography/runs/${jobId}/pdf`, }