Files
cyclone/src/lib/inbox-api.ts
T
Tyler cb74bc9307 test(sp8): add coverage for resubmitRejectedWithDownload + bundle modal flow
- inbox-api.test.ts: pin ?download=true POST contract; blob + filename +
  X-Cyclone-Serialize-Errors parsing; non-2xx error surfacing.
- Inbox.test.tsx: end-to-end multi-select → Resubmit + Download path
  verifies api call args and downloadBlob wiring.
- inbox-api.ts: drop redundant isConfigured short-circuit in download
  variant (backend has its own auth gate) and switch error reading to
  res.text() to match Blob response shape.
2026-06-20 20:53:58 -06:00

180 lines
5.3 KiB
TypeScript

// ---------------------------------------------------------------------------
// Inbox API client (sub-project 6).
//
// Mirrors the four-lane working surface: rejected / candidates / unmatched /
// done_today. Plus the actions: manual match, dismiss, resubmit, export.
// All non-2xx responses throw (so callers can branch on status).
// ---------------------------------------------------------------------------
export type ScoreBreakdown = {
patient: number;
date: number;
amount: number;
provider: number;
};
export type ScoreTier = "strong" | "weak" | "hidden";
export type InboxClaimRow = {
id: string;
kind: "claim";
patient_control_number: string;
charge_amount: number | null;
payer_id: string | null;
provider_npi: string | null;
state: string;
rejection_reason: string | null;
rejected_at: string | null;
service_date_from: string | null;
score?: number | null;
score_tier?: ScoreTier | null;
score_breakdown?: ScoreBreakdown | null;
/**
* SP7: populated on `rejected` / `done_today` claim rows when the claim
* has a matched remittance. Carries the line-count badge the
* `MatchedRemitCard` displays (``3/4 matched lines``).
*/
matched_remittance?: {
id: string;
matched_lines: number;
total_lines: number;
} | null;
};
export type InboxCandidateRow = {
id: string;
kind: "remit";
payer_claim_control_number: string;
charge_amount: number | null;
payer_id: string | null;
rendering_provider_npi: string | null;
service_date: string | null;
candidates: Array<{
claim_id: string;
score: number;
tier: ScoreTier;
breakdown: ScoreBreakdown;
}>;
};
export type InboxLanes = {
rejected: InboxClaimRow[];
candidates: InboxCandidateRow[];
unmatched: InboxClaimRow[];
done_today: InboxClaimRow[];
};
const BASE_URL =
(import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
function joinUrl(path: string): string {
return `${BASE_URL.replace(/\/$/, "")}${path}`;
}
async function getJson<T>(path: string): Promise<T> {
const r = await fetch(joinUrl(path));
if (!r.ok) {
throw new Error(`GET ${path} failed: ${r.status}`);
}
return (await r.json()) as T;
}
async function postJson<T>(path: string, body: unknown): Promise<T> {
const r = await fetch(joinUrl(path), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) {
throw new Error(`POST ${path} failed: ${r.status}`);
}
return (await r.json()) as T;
}
export async function fetchInboxLanes(): Promise<InboxLanes> {
return getJson<InboxLanes>("/api/inbox/lanes");
}
export async function matchCandidate(
remitId: string,
claimId: string,
): Promise<void> {
await postJson(`/api/inbox/candidates/${encodeURIComponent(remitId)}/match`, {
claim_id: claimId,
});
}
export async function dismissCandidates(
pairs: Array<{ claim_id: string; remit_id: string }>,
): Promise<void> {
await postJson("/api/inbox/candidates/dismiss", { pairs });
}
export type ResubmitResult = {
ok: boolean;
resubmitted: string[];
conflicts: Array<{ claim_id: string; current_state: string }>;
};
export async function resubmitRejected(
claimIds: string[],
): Promise<ResubmitResult> {
return postJson<ResubmitResult>("/api/inbox/rejected/resubmit", {
claim_ids: claimIds,
});
}
/**
* Resubmit rejected claims AND ask the backend for a downloadable bundle
* of regenerated 837 files (one .x12 per successfully resubmitted claim).
*
* Returns the raw `Blob` plus the filename the backend suggested in its
* `Content-Disposition` header (e.g. `resubmit-3-claims.zip`). Callers
* usually pipe both into `downloadTextFile`'s sibling helper for binary
* downloads — see `downloadBlob` in `@/lib/download`.
*
* The endpoint never fails because of a partial bundle — per-claim
* serialization errors are surfaced via the `X-Cyclone-Serialize-Errors`
* response header (JSON-encoded array) so the UI can show "10
* resubmitted, 2 couldn't be regenerated" without parsing the zip. The
* returned blob still contains the claims that did serialize.
*/
export async function resubmitRejectedWithDownload(
claimIds: string[],
): Promise<{ blob: Blob; filename: string; serializeErrors: Array<{ claim_id: string; reason: string }> }> {
const res = await fetch(
joinUrl("/api/inbox/rejected/resubmit?download=true"),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ claim_ids: claimIds }),
},
);
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`,
);
}
const blob = await res.blob();
const cd = res.headers.get("content-disposition") ?? "";
const match = /filename="?([^";]+)"?/i.exec(cd);
const filename = match?.[1] ?? "resubmit-bundle.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 {
serializeErrors = [];
}
}
return { blob, filename, serializeErrors };
}
export function exportInboxCsvUrl(
lane: "rejected" | "candidates" | "unmatched" | "done_today",
): string {
return joinUrl(`/api/inbox/export.csv?lane=${lane}`);
}