/** * Cyclone API client. * * Talks to the FastAPI backend that lives at `VITE_API_BASE_URL` * (defaulting to an empty string → "no backend configured" mode). When the * base URL is empty, every method throws `notConfiguredError()`; the * higher-level hooks in `src/hooks/` catch that by reading from the * in-memory zustand store instead. This keeps the pages agnostic of the * switch. * * Endpoints: * - `parse837(file, { onProgress })` POSTs to `/api/parse-837`. With * `onProgress` set, streams NDJSON (one `{type,data}` per line) so the * UI can render claims incrementally. Without it, asks the backend for * a single JSON object via `Accept: application/json`. * - `parse835(...)` mirrors the 837 shape for `/api/parse-835`. * - `health()` GETs `/api/health` and returns `{ status, version }`. * - `listBatches / getBatch / listClaims / listRemittances / listProviders * / getProvider / listActivity` are plain JSON GETs against the * persistence surface. * - `listUnmatched / matchRemit / unmatchClaim` hit the reconciliation * surface. POSTs throw `ApiError` so callers can branch on `.status`. */ import type { Ack, BatchDiff, ClaimAck, ClaimAckKind, ClaimDetail, ClaimOutput, ClaimPayment, Envelope, FinancialInfo, MatchResponse, NdjsonEvent, ParseResult837, ParseResult835, Payer, Payer835, Payee835, Provider, ReassociationTrace, Ta1Ack, UnmatchedClaim, UnmatchedResponse, BatchSummary as ParserBatchSummary, } from "@/types"; import { authedFetch, authedFetchResponse, joinUrl, } from "@/auth/api"; const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? ""; /** * Whether the app should hit the live backend. * * Two paths look identical at runtime: * * - ``VITE_API_BASE_URL`` unset or empty → ``BASE_URL === ""`` and * ``joinUrl()`` produces *relative* URLs (``/api/health`` etc.). * Vite's dev proxy (and nginx in the Docker stack) intercept these * and forward them to the FastAPI server, so the browser sees a * same-origin call. * - ``VITE_API_BASE_URL=https://api.example.com`` → absolute URLs * that bypass the proxy and hit the backend directly (cross-origin * CORS applies). * * We treat both as "configured" because the hooks above (``useClaims`` * etc.) only need to know whether to attempt a network request vs. * fall back to the in-memory zustand store. The proxy is invisible to * the page — it always sees a successful fetch unless the backend is * actually down. The only way to opt out is to set the env var to the * literal string ``"disabled"`` (a sentinel we treat as "no backend"), * which is useful for offline Storybook-style previews. */ export const isConfigured = BASE_URL !== "disabled"; // --------------------------------------------------------------------------- // Shared types // --------------------------------------------------------------------------- export type NdjsonProgressEvent = | { type: "envelope"; data: Envelope | null } | { type: "claim"; data: ClaimOutput } | { type: "claim_payment"; data: ClaimPayment } | { type: "financial_info"; data: FinancialInfo } | { type: "trace"; data: ReassociationTrace } | { type: "payer"; data: Payer | Payer835 } | { type: "payee"; data: Payee835 } | { type: "summary"; data: ParserBatchSummary }; export type ParseProgress = (event: NdjsonProgressEvent) => void; export interface ParseOptions { /** Defaults to "co_medicaid" for 837 / "co_medicaid_835" for 835. */ payer?: string; /** Promote validation warnings to errors on the backend. */ strict?: boolean; /** Include raw EDI segments in the response (default: true). */ includeRawSegments?: boolean; /** * Optional progress callback. When provided, the client streams NDJSON from * the backend and invokes the callback for every line. When omitted, the * client requests a single JSON object via `Accept: application/json`. */ onProgress?: ParseProgress; } export interface HealthResponse { status: string; version: string; } // --------------------------------------------------------------------------- // List-endpoint types (GET surfaces, sub-project 1) // --------------------------------------------------------------------------- export interface ListClaimsParams { batch_id?: string; status?: string; payer?: string; provider_npi?: string; date_from?: string; date_to?: string; sort?: string; order?: "asc" | "desc"; limit?: number; offset?: number; } export interface ListRemittancesParams { batch_id?: string; payer?: string; claim_id?: string; date_from?: string; date_to?: string; sort?: string; order?: "asc" | "desc"; limit?: number; offset?: number; } export interface ListProvidersParams { npi?: string; state?: string; limit?: number; offset?: number; } export interface ListActivityParams { kind?: string; since?: string; limit?: number; } // --------------------------------------------------------------------------- // Dashboard KPI types (SP27 Task 13). // // Returned by ``GET /api/dashboard/kpis`` — server-aggregated over the // *entire* claim population. The Dashboard renders these directly; it // no longer paginates ``/api/claims`` and reduces client-side (which // silently produced wrong numbers with the previous ``limit: 100`` // cap). // --------------------------------------------------------------------------- export interface DashboardTotals { count: number; billed: number; received: number; outstandingAr: number; denied: number; denialRate: number; pending: number; } export interface DashboardMonthly { month: string; // "YYYY-MM" label: string; // "Jan" count: number; billed: number; received: number; denied: number; denialRate: number; ar: number; } export interface DashboardTopProvider { npi: string; label: string; claimCount: number; billed: number; denied: number; } export interface DashboardTopDenial { id: string; patientName: string; billedAmount: number; denialReason: string | null; submissionDate: string; } export interface DashboardKpis { totals: DashboardTotals; monthly: DashboardMonthly[]; topProviders: DashboardTopProvider[]; topDenials: DashboardTopDenial[]; } export interface DashboardKpisParams { months?: number; top_n_providers?: number; top_n_denials?: number; } export interface PaginatedResponse { items: T[]; total: number; returned: number; has_more: boolean; } /** * Server-side aggregates returned alongside `/api/acks` — summed over * the *full* persisted row set (not just the visible page). The * `accepted` / `rejected` / `received` keys match the in-page `totals` * shape so the page can use `data.aggregates` as a drop-in for the * page-local fallback accumulator. */ export interface AckAggregates { accepted: number; rejected: number; received: number; } /** * Lightweight summary used by the GET /api/batches list endpoint. Distinct * from the parser's `BatchSummary` (which lives in `@/types` and carries the * full validation + per-rule counts) — this one is what the UI needs to * render a list row. */ export interface BatchSummary { id: string; kind: "837p" | "835"; inputFilename: string; parsedAt: string; claimCount: number; /** * Per-claim ids for 837P batches. Empty for 835 (no re-export * endpoint) — the UI hides the one-click Re-export button when * this is empty. Lets the History tab call * `POST /api/batches/{id}/export-837` directly with the row's ids * instead of an extra round-trip to `/api/batches/{id}`. */ claimIds: string[]; } // --------------------------------------------------------------------------- // Low-level helpers // --------------------------------------------------------------------------- function notConfiguredError(): Error { return new Error( "API not configured. Set VITE_API_BASE_URL in .env.local to use the backend." ); } /** * Error thrown for non-2xx responses. Carries the HTTP `status` so callers can * branch on it (e.g. 404 vs 500) without parsing the message string. */ export class ApiError extends Error { constructor(public status: number, message: string) { super(message); } } async function readErrorBody(res: Response): Promise { try { const t = await res.text(); if (!t) return ""; // FastAPI errors can be flat `{ "error": "...", "detail": "..." }` // or wrapped as `{ "detail": { "error": "...", "detail": "..." } }` // (the wrapper our app uses for structured errors). Walk into nested // `detail` objects until we hit a string field — guards against the // raw JSON leaking into the user-visible error message. try { let current: unknown = JSON.parse(t); for (let depth = 0; depth < 5; depth++) { if (!current || typeof current !== "object") break; const obj = current as { detail?: unknown; error?: unknown }; if (typeof obj.detail === "string") return obj.detail; if (typeof obj.error === "string") return obj.error; if (obj.detail && typeof obj.detail === "object") { current = obj.detail; continue; } break; } } catch { // not JSON; fall through } return t; } catch { return ""; } } /** Build a query string, skipping undefined/empty params. */ function qs(params: Record | undefined): string { if (!params) return ""; const u = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v === undefined || v === null || v === "") continue; u.set(k, String(v)); } const s = u.toString(); return s ? `?${s}` : ""; } async function parseNdjsonStream( res: Response, onProgress: ParseProgress ): Promise { if (!res.body) { throw new Error("Response had no body to stream."); } const reader = res.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; let lastSummary: ParserBatchSummary | null = null; // Read the body chunk-by-chunk. A `while (true)` loop is the standard // pattern for ReadableStreamDefaultReader; `done` breaks us out. // eslint-disable-next-line no-constant-condition while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Split on newlines. The backend terminates every line with "\n". let nl = buffer.indexOf("\n"); while (nl !== -1) { const line = buffer.slice(0, nl).replace(/\r$/, ""); buffer = buffer.slice(nl + 1); if (line.length > 0) { let event: NdjsonEvent; try { event = JSON.parse(line) as NdjsonEvent; } catch (err) { throw new Error( `Failed to parse NDJSON line: ${ err instanceof Error ? err.message : String(err) }\n${line}` ); } onProgress(event as NdjsonProgressEvent); if (event.type === "summary") { lastSummary = event.data as ParserBatchSummary; } } nl = buffer.indexOf("\n"); } } // Flush any trailing partial line (no final newline). const tail = buffer.replace(/\r$/, ""); if (tail.length > 0) { const event = JSON.parse(tail) as NdjsonEvent; onProgress(event as NdjsonProgressEvent); if (event.type === "summary") { lastSummary = event.data as ParserBatchSummary; } } if (!lastSummary) { throw new Error("Stream ended without a summary line."); } return lastSummary; } // --------------------------------------------------------------------------- // Public surface — parser endpoints // --------------------------------------------------------------------------- async function parse837( file: File, options: ParseOptions = {} ): Promise { if (!isConfigured) throw notConfiguredError(); const { payer = "co_medicaid", strict = false, includeRawSegments = true, onProgress } = options; const params = new URLSearchParams({ payer, strict: String(strict), include_raw_segments: String(includeRawSegments), }); const form = new FormData(); form.append("file", file, file.name); const accept = onProgress ? "application/x-ndjson" : "application/json"; // authedFetchResponse still hits fetch() (and still does the // 401-redirect for any non-/api/auth path), but hands back the raw // Response so we can stream NDJSON line-by-line below. For non-stream // callers it returns the parsed JSON. const res = await authedFetchResponse( `/api/parse-837?${params.toString()}`, { method: "POST", body: form, headers: { Accept: accept }, } ); if (!res.ok) { const detail = await readErrorBody(res); throw new Error( `${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}` ); } if (onProgress) { return parseNdjsonStream(res, onProgress); } return (await res.json()) as ParseResult837; } async function parse835( file: File, options: ParseOptions = {} ): Promise { if (!isConfigured) throw notConfiguredError(); const { payer = "co_medicaid_835", strict = false, includeRawSegments = true, onProgress } = options; const params = new URLSearchParams({ payer, strict: String(strict), include_raw_segments: String(includeRawSegments), }); const form = new FormData(); form.append("file", file, file.name); const accept = onProgress ? "application/x-ndjson" : "application/json"; // See parse837 for why this uses authedFetchResponse instead of // authedFetch — NDJSON streaming needs the raw Response so we can // iterate `res.body` one chunk at a time. const res = await authedFetchResponse( `/api/parse-835?${params.toString()}`, { method: "POST", body: form, headers: { Accept: accept }, } ); if (!res.ok) { const detail = await readErrorBody(res); throw new Error( `${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}` ); } if (onProgress) { return parseNdjsonStream(res, onProgress); } return (await res.json()) as ParseResult835; } /** * Upload a 999 ACK file for parsing. The backend persists the row * and returns both the parsed result and the persisted ack metadata * (including the regenerated raw 999 text for download). */ async function parse999( file: File ): Promise<{ ack: Ack & { raw_999_text: string }; parsed: unknown }> { if (!isConfigured) throw notConfiguredError(); const form = new FormData(); form.append("file", file, file.name); const body = await authedFetch<{ ack: { id: number; accepted_count: number; rejected_count: number; received_count: number; ack_code: "A" | "E" | "R" | "P"; source_batch_id: string; raw_999_text: string; }; parsed: unknown; }>("/api/parse-999", { method: "POST", body: form, // Don't set Content-Type — the browser sets the multipart // boundary when the body is FormData. authedFetch only adds a // default Accept, which we override here. headers: { Accept: "application/json" }, }); return { ack: { id: body.ack.id, sourceBatchId: body.ack.source_batch_id, acceptedCount: body.ack.accepted_count, rejectedCount: body.ack.rejected_count, receivedCount: body.ack.received_count, ackCode: body.ack.ack_code, parsedAt: "", // raw_999_text is appended below (not part of the canonical Ack // type) so the UI can trigger a download without a second // round-trip to /api/acks/{id}. ...({ raw_999_text: body.ack.raw_999_text } as { raw_999_text: string }), } as Ack & { raw_999_text: string }, parsed: body.parsed, }; } async function health(): Promise { if (!isConfigured) return null; // health() is best-effort — used by the side-scan ping. Don't let a // 401 from authedFetch redirect the operator; just return null when // anything goes wrong. try { return await authedFetch("/api/health"); } catch { return null; } } // --------------------------------------------------------------------------- // Public surface — GET endpoints (sub-project 1) // All throw `notConfiguredError` when no backend is wired. The hooks in // `src/hooks/` are responsible for the in-memory zustand fallback. // --------------------------------------------------------------------------- async function listBatches(limit?: number): Promise { if (!isConfigured) throw notConfiguredError(); const params: Record = {}; if (limit !== undefined) params.limit = limit; const body = await authedFetch<{ items: BatchSummary[] }>( `/api/batches${qs(params)}` ); return body.items; } async function getBatch(id: string): Promise { if (!isConfigured) throw notConfiguredError(); return authedFetch( `/api/batches/${encodeURIComponent(id)}` ); } async function listClaims( params: ListClaimsParams ): Promise> { if (!isConfigured) throw notConfiguredError(); return authedFetch>( `/api/claims${qs(params as Record)}` ); } /** * Fetch one claim with the full SP4 detail-drawer context (header, state, * parties, validation, service lines, diagnoses, raw segments, recent * ``stateHistory``, and a populated ``matchedRemittance`` when paired). * * Drives ``GET /api/claims/{claim_id}``. Throws ``ApiError`` on non-2xx — * including 404, which the spec §3.4 calls out as a distinct "claim * doesn't exist" state in the drawer (separate from a transient fetch * failure). Callers should branch on ``error.status === 404`` to render * the not-found state instead of the generic error toast. */ async function getClaimDetail(id: string): Promise { if (!isConfigured) throw notConfiguredError(); try { return await authedFetch( `/api/claims/${encodeURIComponent(id)}` ); } catch (err) { // authedFetch throws the richer auth/api.ts ApiError (carries // `status` + `code` + `detail`). The drawer code branches on // `.status === 404` for the "claim doesn't exist" state, and // existing callers use `instanceof ApiError` (the lib/api.ts one) // to detect structured failures. Re-wrap into the lib/api.ts // ApiError so both keep working unchanged. if ( err && typeof err === "object" && "status" in err && typeof (err as { status: unknown }).status === "number" ) { const e = err as { status: number; code?: string; detail?: string }; throw new ApiError( e.status, e.detail ?? e.code ?? "error" ); } throw err; } } /** * Serialize a claim back to an X12 837P file via the backend's outbound * serializer (SP8). * * Returns both the regenerated X12 text and the filename the backend * suggested (from the `Content-Disposition: attachment; filename=...` * header). Callers usually pipe both into `downloadTextFile` so the user * gets a byte-faithful file named after the claim id. * * Throws `ApiError` on non-2xx — 404 (claim missing) and 422 (stored * `raw_json` unparseable / serializer rejected the payload) are both * reachable, and the caller may want to branch on `.status` to surface a * useful message ("claim data is corrupted — open the batch file * manually" vs. "this claim no longer exists"). */ async function serializeClaim837( id: string, ): Promise<{ text: string; filename: string }> { if (!isConfigured) throw notConfiguredError(); // authedFetchText gives us the body as a string with the same // 401-redirect + error-shape behavior as authedFetch. We need the // Response object for the Content-Disposition header, so this // function delegates to authedFetchResponse and reads text + // headers itself. No Accept negotiation — the endpoint always // returns text/x12. const res = await authedFetchResponse( `/api/claims/${encodeURIComponent(id)}/serialize-837` ); if (!res.ok) { const detail = await readErrorBody(res); throw new ApiError(res.status, detail || res.statusText); } const text = await res.text(); // Filename comes from Content-Disposition (set by the backend). Fall back // to a sensible default if the header is missing or malformed so the UI // always has something to hand to `downloadTextFile`. const cd = res.headers.get("content-disposition") ?? ""; const match = /filename="?([^";]+)"?/i.exec(cd); const filename = match?.[1] ?? `claim-${id}.x12`; return { text, filename }; } async function listRemittances( params: ListRemittancesParams ): Promise> { if (!isConfigured) throw notConfiguredError(); return authedFetch>( `/api/remittances${qs(params as Record)}` ); } /** * Server-aggregated KPI tiles for the Remittances page. Returns the * full-population ``count``, ``total_paid``, and ``total_adjustments`` * — NOT a page-limited sample — so the KPI tiles can't silently * understate the true DB population the way a page-local * ``items.reduce(...)`` would (commits ``59c3275``, ``d81b6ed``). */ export interface RemittanceSummary { count: number; total_paid: number; total_adjustments: number; } async function listRemittanceSummary( params: ListRemittancesParams = {} ): Promise { if (!isConfigured) throw notConfiguredError(); return authedFetch( `/api/remittances/summary${qs(params as Record)}` ); } /** * Fetch one remittance with its labeled CAS `adjustments` array. * Throws `ApiError` on 404 so callers can branch on `.status`. */ async function getRemittance(id: string): Promise { if (!isConfigured) throw notConfiguredError(); return authedFetch(`/api/remittances/${encodeURIComponent(id)}`); } /** * Fetch the side-by-side diff between two batches. * * Drives ``GET /api/batch-diff?a=&b=``. Both ids * are required; the endpoint returns 400 when either is missing and * 404 when the id is unknown — both surface here as ``ApiError`` so * the page can branch on ``.status`` like every other diff / fetch * surface in the app. */ async function getBatchDiff(a: string, b: string): Promise { if (!isConfigured) throw notConfiguredError(); // Defense-in-depth: even though the `useBatchDiff` hook gates on both // ids being non-empty, refuse to fire the request if either is // missing. Surfaces a clear local error instead of letting the // backend return its generic "Missing param" 400. if (typeof a !== "string" || a.length === 0) { throw new ApiError(400, "Missing param: ?a= is required."); } if (typeof b !== "string" || b.length === 0) { throw new ApiError(400, "Missing param: ?b= is required."); } return authedFetch(`/api/batch-diff?${qs({ a, b })}`); } async function listProviders( params: ListProvidersParams = {} ): Promise> { if (!isConfigured) throw notConfiguredError(); return authedFetch>( `/api/providers${qs(params as Record)}` ); } /** * Fetch one configured provider by NPI, used by the provider drill-down * drawer (SP21 Task 2.2 / 1.6). * * Drives ``GET /api/config/providers/{npi}``. Note the ``/api/config/`` * prefix — this is the config-side route namespace, distinct from the * persistence-side ``/api/providers`` used by ``listProviders``. The * response is the full ``Provider`` shape (including the optional * ``recent_claims`` and ``recent_activity`` arrays populated by Task * 1.6 when the backend can serve them). * * Throws ``ApiError`` on non-2xx — including 404, which the drawer may * want to branch on for a "provider no longer configured" state. */ async function getProvider(npi: string): Promise { if (!isConfigured) throw notConfiguredError(); const res = await fetch( joinUrl(`/api/config/providers/${encodeURIComponent(npi)}`), { headers: { Accept: "application/json" } } ); if (!res.ok) { const detail = await readErrorBody(res); throw new ApiError(res.status, detail || res.statusText); } return (await res.json()) as Provider; } /** * Aggregate stats for one payer, used by the peek modal on Dashboard / * Claims tables (SP21 universal drill-down). Drives * `GET /api/payers/{payer_id}/summary`. * * `denial_rate` is a fraction in `[0, 1]` (the API does NOT pre-multiply). * `top_providers` is the top 3 (or fewer) by claim count, ordered * server-side. */ export interface PayerSummary { payer_id: string; name: string; claim_count: number; billed_total: number; received_total: number; denial_rate: number; top_providers: Array<{ npi: string; count: number }>; } async function getPayerSummary(payerId: string): Promise { if (!isConfigured) throw notConfiguredError(); const res = await fetch( joinUrl(`/api/payers/${encodeURIComponent(payerId)}/summary`) ); if (!res.ok) { const detail = await readErrorBody(res); throw new Error( `${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}` ); } return (await res.json()) as PayerSummary; } async function listActivity( params: ListActivityParams = {} ): Promise> { if (!isConfigured) throw notConfiguredError(); return authedFetch>( `/api/activity${qs(params as Record)}` ); } /** * Fetch server-aggregated Dashboard KPIs. * * Drives ``GET /api/dashboard/kpis``. Computes billed / received / * denial rate / pending AR / top providers / top denials server-side * over the *full* claim population so the Dashboard's numbers are * always correct regardless of dataset size. With 60k+ claims in * production, fetching ``/api/claims?limit=100`` and reducing * client-side silently produced wrong KPIs — this endpoint replaces * that pattern. */ async function getDashboardKpis( params: DashboardKpisParams = {} ): Promise { if (!isConfigured) throw notConfiguredError(); return authedFetch( `/api/dashboard/kpis${qs(params as Record)}` ); } // --------------------------------------------------------------------------- // Public surface — reconciliation endpoints (sub-project 2) // POSTs throw `ApiError` so callers can inspect `.status`; the GET is shaped // like the other list endpoints above. // --------------------------------------------------------------------------- async function listUnmatched(): Promise { if (!isConfigured) throw notConfiguredError(); return authedFetch(`/api/reconciliation/unmatched`); } async function matchRemit( claimId: string, remitId: string ): Promise { if (!isConfigured) throw notConfiguredError(); return authedFetch(`/api/reconciliation/match`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ claim_id: claimId, remit_id: remitId }), }); } async function unmatchClaim(claimId: string): Promise<{ claim: UnmatchedClaim }> { if (!isConfigured) throw notConfiguredError(); return authedFetch<{ claim: UnmatchedClaim }>(`/api/reconciliation/unmatch`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ claim_id: claimId }), }); } // --------------------------------------------------------------------------- // Public surface — 999 ACKs (SP3 P3 T16) // Re-shapes the snake_case backend response into the camelCase `Ack` shape // used throughout the UI. // --------------------------------------------------------------------------- /** * Raw snake_case row from `GET /api/acks`. Re-shaped by `mapAck` * before the UI sees it. */ interface RawAckRow { id: number; source_batch_id: string; accepted_count: number; rejected_count: number; received_count: number; ack_code: "A" | "E" | "R" | "P"; parsed_at: string; /** * SP28: distinct claim ids this 999 ack is linked to. Empty * array when the auto-linker couldn't resolve (orphan). */ linked_claim_ids?: string[]; } function mapAck(row: RawAckRow): Ack { return { id: row.id, sourceBatchId: row.source_batch_id, acceptedCount: row.accepted_count, rejectedCount: row.rejected_count, receivedCount: row.received_count, ackCode: row.ack_code, parsedAt: row.parsed_at, linkedClaimIds: row.linked_claim_ids ?? [], }; } async function listAcks( params: { limit?: number; offset?: number } = {}, ): Promise< PaginatedResponse & { aggregates: AckAggregates; } > { if (!isConfigured) throw notConfiguredError(); const query: Record = {}; if (params.limit !== undefined) query.limit = params.limit; if (params.offset !== undefined) query.offset = params.offset; const body = await authedFetch<{ items: RawAckRow[]; total: number; returned: number; has_more: boolean; aggregates: { accepted_count: number; rejected_count: number; received_count: number }; }>(`/api/acks${qs(query)}`); return { items: body.items.map(mapAck), total: body.total, returned: body.returned, has_more: body.has_more, // Adapt the wire-format `*_count` keys to the in-page `totals` shape // so the page can treat server-side and client-side-fallback objects // interchangeably. See useAcks.ts for the matching AckAggregates type. aggregates: { accepted: body.aggregates.accepted_count, rejected: body.aggregates.rejected_count, received: body.aggregates.received_count, }, }; } async function getAck(id: number): Promise { if (!isConfigured) throw notConfiguredError(); const row = await authedFetch( `/api/acks/${encodeURIComponent(String(id))}` ); return { ...mapAck(row), rawJson: row.raw_json }; } // --------------------------------------------------------------------------- // Public surface — TA1 ACKs // The TA1 is the lowest-level X12 envelope ack (one per inbound // ISA/IEA interchange), distinct from the per-batch 999. Colorado // Medicaid's Gainwell MFT only ships 999s today, but historically // they've sent TA1s, so the UI shows them whenever one is on file. // --------------------------------------------------------------------------- interface RawTa1Row { id: number; control_number: string; ack_code: "A" | "E" | "R"; note_code: string | null; interchange_date: string | null; interchange_time: string | null; sender_id: string | null; receiver_id: string | null; source_batch_id: string; parsed_at: string | null; /** * SP28: originating `Batch` ids this TA1 envelope is linked to. * Empty array when the auto-linker couldn't resolve (orphan). */ linked_claim_ids?: string[]; } function mapTa1Ack(row: RawTa1Row): Ta1Ack { return { id: row.id, controlNumber: row.control_number, ackCode: row.ack_code, noteCode: row.note_code, interchangeDate: row.interchange_date, interchangeTime: row.interchange_time, senderId: row.sender_id, receiverId: row.receiver_id, sourceBatchId: row.source_batch_id, parsedAt: row.parsed_at, linkedClaimIds: row.linked_claim_ids ?? [], }; } async function listTa1Acks( params: { limit?: number } = {}, ): Promise> { if (!isConfigured) throw notConfiguredError(); const query: Record = {}; if (params.limit !== undefined) query.limit = params.limit; const body = await authedFetch<{ items: RawTa1Row[]; total: number; }>(`/api/ta1-acks${qs(query)}`); // The TA1 list endpoint returns `{ total, items }` (no `has_more` / // `returned` — it's a simple cap-based list, not a paginated one). // Synthesize the `PaginatedResponse` shape so the UI can share the // same hook contract as `listAcks`. return { items: body.items.map(mapTa1Ack), total: body.total, returned: body.items.length, has_more: body.items.length < body.total, }; } // --------------------------------------------------------------------------- // SP28: claim↔ack link rows. // The `claim_acks` table is the durable record of which 999 / 277CA / TA1 // acknowledged which claim (or, for TA1, which originating 837 `Batch`). // Three read endpoints (`listClaimAcks`, `listAckClaims`, `listAckOrphans`) // plus the manual-match + unmatch write endpoints. The wire shape mirrors // `to_ui_claim_ack` (backend/src/cyclone/store/ui.py) — camelCase keys, // ISO Z timestamps, numeric ids from the database row. // --------------------------------------------------------------------------- interface RawClaimAckRow { id: number; claim_id: string | null; batch_id: string | null; ack_id: number; ack_kind: ClaimAckKind; ak2_index: number | null; set_control_number: string | null; set_accept_reject_code: string | null; linked_at: string; linked_by: "auto" | "manual"; claim_state?: string; } function mapClaimAck(row: RawClaimAckRow): ClaimAck { return { id: row.id, claimId: row.claim_id, batchId: row.batch_id, ackId: row.ack_id, ackKind: row.ack_kind, ak2Index: row.ak2_index, setControlNumber: row.set_control_number, setAcceptRejectCode: row.set_accept_reject_code, linkedAt: row.linked_at, linkedBy: row.linked_by, claimState: row.claim_state, }; } /** * `GET /api/claims/{claim_id}/acks` — list every `claim_acks` row * for the given claim (per-claim only; TA1 batch-level rows with * `claim_id IS NULL` are filtered out by the backend). * * Drives `ClaimDrawer`'s new `` panel. Returns * an array even on the empty case — the panel renders * `` when the list is empty (no live-tail data). */ async function listClaimAcks(claimId: string): Promise { if (!isConfigured) throw notConfiguredError(); const body = await authedFetch<{ items: RawClaimAckRow[] }>( `/api/claims/${encodeURIComponent(claimId)}/acks`, ); return body.items.map(mapClaimAck); } /** * `GET /api/acks/{kind}/{ack_id}/claims` — list every `claim_acks` * row for the given ack. For 999/277CA, one entry per AK2 / * ClaimStatus (D1 per-AK2 granularity). For TA1, one entry per * envelope linked to the originating `Batch`. * * Drives `AckDrawer`'s new `` panel. Returns an * array; the panel renders the "Link to claim…" dropdown when the * list is empty (no auto-link resolved). */ async function listAckClaims( kind: ClaimAckKind, ackId: number, ): Promise { if (!isConfigured) throw notConfiguredError(); const body = await authedFetch<{ items: RawClaimAckRow[] }>( `/api/acks/${encodeURIComponent(kind)}/${encodeURIComponent(String(ackId))}/claims`, ); return body.items.map(mapClaimAck); } /** * `POST /api/acks/{kind}/{ack_id}/match-claim` — manual-match fallback * (D5). Any logged-in user can run this (D9 differs explicitly from * the admin-only remit-orphans posture — the metadata-only nature of * an ack link doesn't warrant admin gating). * * Idempotent on the server: re-running with the same `claim_id` * returns the existing row with HTTP 200. Returns 409 if the target * claim is in a terminal state (REVERSED). */ async function matchAckToClaim( kind: ClaimAckKind, ackId: number, claimId: string, ): Promise { if (!isConfigured) throw notConfiguredError(); const body = await authedFetch( `/api/acks/${encodeURIComponent(kind)}/${encodeURIComponent(String(ackId))}/match-claim`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ claim_id: claimId }), }, ); return mapClaimAck(body); } /** * `DELETE /api/acks/{kind}/{ack_id}/match-claim/{claim_id}` — unlink. * Removes the `claim_acks` row and publishes a `claim_ack_dropped` * event; does NOT revert any `Claim.state` mutation the ack may * have triggered (e.g. an `apply_999_rejections` flip to REJECTED * stays — unlinking is metadata-only, per spec §D5/§D6). */ async function unmatchAck( kind: ClaimAckKind, ackId: number, claimId: string, ): Promise { if (!isConfigured) throw notConfiguredError(); await authedFetch( `/api/acks/${encodeURIComponent(kind)}/${encodeURIComponent(String(ackId))}/match-claim/${encodeURIComponent(claimId)}`, { method: "DELETE" }, ); } /** * One row in the Inbox "Ack orphans" lane (D7). Mirrors the wire * shape of `GET /api/inbox/ack-orphans?kind=…`. ``candidates`` is a * top-3 list of relaxed PCN matches so the operator can decide * whether to dismiss or manually link. */ export interface AckOrphanRow { id: number; kind: ClaimAckKind; pcn: string; sourceBatchId: string; parsedAt: string; candidates: Array<{ claimId: string; score: number; tier: "strong" | "weak" | "hidden"; }>; } /** * `GET /api/inbox/ack-orphans?kind=…` — list acks that the * auto-linker couldn't resolve to a claim. Powers the Inbox * "Ack orphans" lane (mirrors the existing "Payer-rejected" lane * shape; D7). */ async function listAckOrphans(kind: ClaimAckKind): Promise { if (!isConfigured) throw notConfiguredError(); const body = await authedFetch<{ items: AckOrphanRow[]; total: number; }>(`/api/inbox/ack-orphans${qs({ kind })}`); // Defensive: the test harness can stub fetch with a different // response shape. Treat a missing `items` as an empty list so a // broken mock doesn't crash the whole inbox. return Array.isArray(body.items) ? body.items : []; } /** * Download a ZIP of regenerated X12 837 files for a parsed batch. * * Drives `POST /api/batches/{batchId}/export-837` with the requested * claim_ids in the body. The backend returns a binary ZIP whose * entries follow the HCPF X12 File Naming Standards template * ``{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (one file per * successfully serialized claim, with a per-claim millisecond offset * so every entry has a unique 17-digit timestamp). The ZIP itself is * named ``batch-{batchId}-{N}-claims.zip`` via Content-Disposition, * where N is the success count. * * Per-claim serialization failures are surfaced via the * `X-Cyclone-Serialize-Errors` response header (JSON-encoded array of * `{claim_id, reason}`). The ZIP still contains the successful claims; * the failures are returned alongside so the UI can show a partial- * success toast like "Exported 18 · 2 couldn't be regenerated". * * Throws `ApiError` on non-2xx — 404 (batch missing) is the most * likely case. The client-side guard rejects empty `claimIds` without * making a request. */ export interface BatchExportResult { blob: Blob; filename: string; serializeErrors: Array<{ claim_id: string; reason: string }>; } async function exportBatch837( batchId: string, claimIds: string[], ): Promise { if (!isConfigured) throw notConfiguredError(); if (claimIds.length === 0) { throw new Error("claimIds is empty"); } const res = await fetch( joinUrl(`/api/batches/${encodeURIComponent(batchId)}/export-837`), { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/zip", }, body: JSON.stringify({ claim_ids: claimIds }), }, ); if (!res.ok) { const detail = await readErrorBody(res); throw new ApiError(res.status, detail || res.statusText); } const blob = await res.blob(); const cd = res.headers.get("content-disposition") ?? ""; const match = /filename="?([^";]+)"?/i.exec(cd); const filename = match?.[1] ?? `batch-${batchId}-claims.zip`; const errHeader = res.headers.get("x-cyclone-serialize-errors"); let serializeErrors: Array<{ claim_id: string; reason: string }> = []; if (errHeader) { try { serializeErrors = JSON.parse(errHeader); } catch { // Malformed header — treat as empty rather than failing the download. serializeErrors = []; } } return { blob, filename, serializeErrors }; } export const api = { isConfigured, baseUrl: BASE_URL, health, parse837, parse835, parse999, listBatches, getBatch, getBatchDiff, listClaims, getClaimDetail, serializeClaim837, exportBatch837, listRemittances, listRemittanceSummary, getRemittance, listProviders, getProvider, getPayerSummary, listActivity, listUnmatched, matchRemit, unmatchClaim, listAcks, getAck, listTa1Acks, listClaimAcks, listAckClaims, matchAckToClaim, unmatchAck, listAckOrphans, getDashboardKpis, };