feat(sp6): inbox api client + types
This commit is contained in:
@@ -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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportInboxCsvUrl(
|
||||||
|
lane: "rejected" | "candidates" | "unmatched" | "done_today",
|
||||||
|
): string {
|
||||||
|
return joinUrl(`/api/inbox/export.csv?lane=${lane}`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user