Files
cyclone/src/lib/api.ts
T
tyler 1381a7652d fix(acks): make 999 source_batch_id unique per file + surface PCN
Gainwell's MFT ships every 999 with the same ISA interchange
control number (`000000001`) and one 999 ack covers a whole
batch, not a single claim — so the AK2 set_control_number
(patient_control_number) is the same for the ~96 999s in a
batch. With the old synthetic-id formula (`999-{icn}`), all 385
daily acks collapsed onto a single row the operator couldn't
distinguish.

The new formula is `999-{pcn}-{filename_hash8}` (or
`999-{icn}-{filename_hash8}` for envelope-only 999s without an
AK2). The PCN gives the operator a human-readable handle to the
claim batch; the 8-char hash of the inbound filename guarantees
uniqueness within a batch. Fits in the VARCHAR(32) source_batch_id
column (max 22 chars).

Also surface `patient_control_number` in /api/acks list response
(extracted from raw_json's set_responses[0].set_control_number)
and in the Acks UI as the primary label, with the synthetic id
shown dimmed after a middle dot. The detail endpoint already
exposed raw_json for the full 999 parse tree.
2026-06-24 23:55:58 -06:00

877 lines
29 KiB
TypeScript

/**
* 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,
ClaimDetail,
ClaimOutput,
ClaimPayment,
Envelope,
FinancialInfo,
MatchResponse,
NdjsonEvent,
ParseResult837,
ParseResult835,
Payer,
Payer835,
Payee835,
Provider,
ReassociationTrace,
UnmatchedClaim,
UnmatchedResponse,
BatchSummary as ParserBatchSummary,
} from "@/types";
import {
authedFetch,
authedFetchResponse,
} 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;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
returned: number;
has_more: boolean;
}
/**
* 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<string> {
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<string, unknown> | 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<ParserBatchSummary> {
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<ParseResult837 | ParserBatchSummary> {
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<ParseResult835 | ParserBatchSummary> {
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<HealthResponse | null> {
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<HealthResponse>("/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<BatchSummary[]> {
if (!isConfigured) throw notConfiguredError();
const params: Record<string, unknown> = {};
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<ParseResult837 | ParseResult835> {
if (!isConfigured) throw notConfiguredError();
return authedFetch<ParseResult837 | ParseResult835>(
`/api/batches/${encodeURIComponent(id)}`
);
}
async function listClaims<T = unknown>(
params: ListClaimsParams
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
return authedFetch<PaginatedResponse<T>>(
`/api/claims${qs(params as Record<string, unknown>)}`
);
}
/**
* 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<ClaimDetail> {
if (!isConfigured) throw notConfiguredError();
try {
return await authedFetch<ClaimDetail>(
`/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<T = unknown>(
params: ListRemittancesParams
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
return authedFetch<PaginatedResponse<T>>(
`/api/remittances${qs(params as Record<string, unknown>)}`
);
}
/**
* Fetch one remittance with its labeled CAS `adjustments` array.
* Throws `ApiError` on 404 so callers can branch on `.status`.
*/
async function getRemittance<T = unknown>(id: string): Promise<T> {
if (!isConfigured) throw notConfiguredError();
return authedFetch<T>(`/api/remittances/${encodeURIComponent(id)}`);
}
/**
* Fetch the side-by-side diff between two batches.
*
* Drives ``GET /api/batch-diff?a=<batch_id>&b=<batch_id>``. 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<BatchDiff> {
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=<batch_id> is required.");
}
if (typeof b !== "string" || b.length === 0) {
throw new ApiError(400, "Missing param: ?b=<batch_id> is required.");
}
return authedFetch<BatchDiff>(`/api/batch-diff?${qs({ a, b })}`);
}
async function listProviders<T = unknown>(
params: ListProvidersParams = {}
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
return authedFetch<PaginatedResponse<T>>(
`/api/providers${qs(params as Record<string, unknown>)}`
);
}
/**
* 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<Provider> {
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<PayerSummary> {
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<T = unknown>(
params: ListActivityParams = {}
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
return authedFetch<PaginatedResponse<T>>(
`/api/activity${qs(params as Record<string, unknown>)}`
);
}
// ---------------------------------------------------------------------------
// 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<UnmatchedResponse> {
if (!isConfigured) throw notConfiguredError();
return authedFetch<UnmatchedResponse>(`/api/reconciliation/unmatched`);
}
async function matchRemit(
claimId: string,
remitId: string
): Promise<MatchResponse> {
if (!isConfigured) throw notConfiguredError();
return authedFetch<MatchResponse>(`/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;
patient_control_number?: string | null;
}
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,
patientControlNumber: row.patient_control_number ?? null,
};
}
async function listAcks(params: { limit?: number } = {}): Promise<PaginatedResponse<Ack>> {
if (!isConfigured) throw notConfiguredError();
const query: Record<string, unknown> = {};
if (params.limit !== undefined) query.limit = params.limit;
const body = await authedFetch<{
items: RawAckRow[];
total: number;
returned: number;
has_more: boolean;
}>(`/api/acks${qs(query)}`);
return {
items: body.items.map(mapAck),
total: body.total,
returned: body.returned,
has_more: body.has_more,
};
}
async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
if (!isConfigured) throw notConfiguredError();
const row = await authedFetch<RawAckRow & { raw_json: unknown }>(
`/api/acks/${encodeURIComponent(String(id))}`
);
return { ...mapAck(row), rawJson: row.raw_json };
}
/**
* 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<BatchExportResult> {
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,
getRemittance,
listProviders,
getProvider,
getPayerSummary,
listActivity,
listUnmatched,
matchRemit,
unmatchClaim,
listAcks,
getAck,
};