From f3e5d800e1f25695b2830b8e48858a96cb56456d Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 18:36:14 -0600 Subject: [PATCH] feat(sp6): inbox api client + types --- src/lib/inbox-api.test.ts | 71 ++++++++++++++++++++++ src/lib/inbox-api.ts | 121 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/lib/inbox-api.test.ts create mode 100644 src/lib/inbox-api.ts diff --git a/src/lib/inbox-api.test.ts b/src/lib/inbox-api.test.ts new file mode 100644 index 0000000..b2c8547 --- /dev/null +++ b/src/lib/inbox-api.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fetchInboxLanes, + matchCandidate, + dismissCandidates, + resubmitRejected, + exportInboxCsvUrl, +} from "./inbox-api"; + +const BASE = "http://test.local"; + +describe("inbox-api", () => { + it("fetchInboxLanes hits /api/inbox/lanes", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ rejected: [], candidates: [], unmatched: [], done_today: [] }), + }); + vi.stubGlobal("fetch", fetchSpy); + const lanes = await fetchInboxLanes(); + expect(fetchSpy).toHaveBeenCalledWith(`${BASE}/api/inbox/lanes`); + expect(lanes.rejected).toEqual([]); + vi.unstubAllGlobals(); + }); + + it("matchCandidate posts to the candidates/{remit_id}/match endpoint", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }); + vi.stubGlobal("fetch", fetchSpy); + await matchCandidate("R-1", "C-1"); + expect(fetchSpy).toHaveBeenCalledWith( + `${BASE}/api/inbox/candidates/R-1/match`, + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ claim_id: "C-1" }), + }), + ); + vi.unstubAllGlobals(); + }); + + it("dismissCandidates posts pairs", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }); + vi.stubGlobal("fetch", fetchSpy); + await dismissCandidates([{ claim_id: "C-1", remit_id: "R-1" }]); + expect(fetchSpy).toHaveBeenCalledWith( + `${BASE}/api/inbox/candidates/dismiss`, + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ pairs: [{ claim_id: "C-1", remit_id: "R-1" }] }), + }), + ); + vi.unstubAllGlobals(); + }); + + it("resubmitRejected posts claim_ids", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true, resubmitted: ["C-1"], conflicts: [] }), + }); + vi.stubGlobal("fetch", fetchSpy); + const r = await resubmitRejected(["C-1"]); + expect(fetchSpy).toHaveBeenCalledWith( + `${BASE}/api/inbox/rejected/resubmit`, + expect.objectContaining({ method: "POST" }), + ); + expect(r.resubmitted).toEqual(["C-1"]); + vi.unstubAllGlobals(); + }); + + it("exportInboxCsvUrl returns the export endpoint with the lane", () => { + expect(exportInboxCsvUrl("rejected")).toBe(`${BASE}/api/inbox/export.csv?lane=rejected`); + }); +}); diff --git a/src/lib/inbox-api.ts b/src/lib/inbox-api.ts new file mode 100644 index 0000000..dd6bf92 --- /dev/null +++ b/src/lib/inbox-api.ts @@ -0,0 +1,121 @@ +// --------------------------------------------------------------------------- +// 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; +}; + +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(path: string): Promise { + 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(path: string, body: unknown): Promise { + 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 { + return getJson("/api/inbox/lanes"); +} + +export async function matchCandidate( + remitId: string, + claimId: string, +): Promise { + await postJson(`/api/inbox/candidates/${encodeURIComponent(remitId)}/match`, { + claim_id: claimId, + }); +} + +export async function dismissCandidates( + pairs: Array<{ claim_id: string; remit_id: string }>, +): Promise { + 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 { + return postJson("/api/inbox/rejected/resubmit", { + claim_ids: claimIds, + }); +} + +export function exportInboxCsvUrl( + lane: "rejected" | "candidates" | "unmatched" | "done_today", +): string { + return joinUrl(`/api/inbox/export.csv?lane=${lane}`); +}