365 lines
15 KiB
TypeScript
365 lines
15 KiB
TypeScript
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<T>(path: string, init?: RequestInit): Promise<T> {
|
|
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<T>
|
|
}
|
|
|
|
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 Gulf Coast upload surfaces under ECW and reads as a failed
|
|
// upload on the Tebra board.
|
|
return req<Batch>(`/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<Batch>('/api/batches/from-runs', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ job_ids: jobIds, stage }),
|
|
}),
|
|
|
|
listBatches: () => req<{ batches: Batch[] }>('/api/batches'),
|
|
|
|
getBatch: (batchId: string) => req<Batch>(`/api/batches/${batchId}`),
|
|
|
|
runBatch: (batchId: string, model?: string) =>
|
|
req<Batch & { started: string[] }>(`/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<Batch>(`/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<ResultRow['Group'], number>; 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<Batch>(`/api/batches/${batchId}/approve-all`, { method: 'POST' }),
|
|
|
|
setBatchStage: (batchId: string, stage: BatchStage) =>
|
|
req<Batch>(`/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<Batch & { note: string; mode: string }>(
|
|
`/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<Run>(`/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<ReviewRow>(`/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<ReviewRow>(`/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<ManagedUser>(`/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<DemographyRun>(`/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 <a href> 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`,
|
|
}
|