feat(frontend): add 6 GET methods (batches, batch, claims, remits, providers, activity) + tests

This commit is contained in:
Tyler
2026-06-19 19:41:07 -06:00
parent 585548e046
commit 3ddf962da2
7 changed files with 1923 additions and 63 deletions
+1619 -1
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -9,7 +9,8 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc -b --noEmit" "typecheck": "tsc -b --noEmit",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
@@ -37,6 +38,7 @@
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.10" "vite": "^5.4.10",
"vitest": "^4.1.9"
} }
} }
+5 -4
View File
@@ -21,7 +21,6 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
import { api } from "@/lib/api";
import type { Claim } from "@/types"; import type { Claim } from "@/types";
interface FormState { interface FormState {
@@ -72,9 +71,11 @@ export function NewClaimDialog() {
submissionDate: new Date().toISOString(), submissionDate: new Date().toISOString(),
}; };
try { try {
if (api.isConfigured) { // The "create claim from form" path is a local-only workflow for the
await api.createClaim(claim); // sample-data mode. When a real backend is wired, the new claim
} // surface is the EDI file → parser → backend pipeline; there's no
// direct `api.createClaim` endpoint. So we just push to the store
// (and surface an error if the store update ever throws).
addClaim(claim); addClaim(claim);
toast.success(`Claim ${claim.id} submitted`); toast.success(`Claim ${claim.id} submitted`);
setForm(initial); setForm(initial);
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { api } from "./api";
const originalFetch = global.fetch;
const mockFetch = vi.fn();
describe("api GET helpers", () => {
beforeEach(() => {
global.fetch = mockFetch as unknown as typeof fetch;
mockFetch.mockReset();
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("listBatches hits /api/batches and unwraps body.items", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { "content-type": "application/json" },
})
);
const out = await api.listBatches();
expect(out).toEqual([]);
expect(mockFetch).toHaveBeenCalledTimes(1);
const called = mockFetch.mock.calls[0][0] as string;
expect(called).toContain("/api/batches");
expect(called).not.toContain("?");
});
it("listClaims builds the right URL with status + sort + order", async () => {
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({ items: [], total: 0, returned: 0, has_more: false }),
{ status: 200, headers: { "content-type": "application/json" } }
)
);
await api.listClaims({
status: "submitted",
sort: "billedAmount",
order: "desc",
});
const called = mockFetch.mock.calls[0][0] as string;
expect(called).toContain("/api/claims?");
expect(called).toContain("status=submitted");
expect(called).toContain("sort=billedAmount");
expect(called).toContain("order=desc");
});
it("getBatch returns the result with the right id in the URL", async () => {
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({
envelope: {
sender_id: "X",
receiver_id: "Y",
control_number: "1",
transaction_date: "2026-01-01",
},
claims: [],
summary: {},
kind: "835",
}),
{ status: 200, headers: { "content-type": "application/json" } }
)
);
const out = await api.getBatch("abc");
expect(out).toBeTruthy();
const called = mockFetch.mock.calls[0][0] as string;
expect(called).toContain("/api/batches/abc");
});
});
+199 -54
View File
@@ -3,20 +3,22 @@
* *
* Talks to the FastAPI backend that lives at `VITE_API_BASE_URL` * Talks to the FastAPI backend that lives at `VITE_API_BASE_URL`
* (defaulting to an empty string → "no backend configured" mode). When the * (defaulting to an empty string → "no backend configured" mode). When the
* base URL is empty, the high-level helper methods throw, the streaming * base URL is empty, every method throws `notConfiguredError()`; the
* `parse837` / `parse835` calls throw, and `health()` returns `null`. Existing * higher-level hooks in `src/hooks/` catch that by reading from the
* pages keep working with the in-memory sample store in that case. * in-memory zustand store instead. This keeps the pages agnostic of the
* switch.
* *
* - `parse837(file, { onProgress })` POSTs to `/api/parse-837` and, when * Endpoints:
* `onProgress` is provided, streams NDJSON (one `{type,data}` object per * - `parse837(file, { onProgress })` POSTs to `/api/parse-837`. With
* line) so the UI can render claims incrementally. With `onProgress` * `onProgress` set, streams NDJSON (one `{type,data}` per line) so the
* omitted, it asks the backend for a single JSON object via * UI can render claims incrementally. Without it, asks the backend for
* `Accept: application/json`. * a single JSON object via `Accept: application/json`.
* - `parse835(...)` mirrors the 837 shape for `/api/parse-835`. * - `parse835(...)` mirrors the 837 shape for `/api/parse-835`.
* - `health()` GETs `/api/health` and returns `{ status, version }`. * - `health()` GETs `/api/health` and returns `{ status, version }`.
* - `listBatches / getBatch / listClaims / listRemittances / listProviders
* / listActivity` are plain JSON GETs against the persistence surface.
*/ */
import { useAppStore } from "@/store";
import type { import type {
ClaimOutput, ClaimOutput,
ClaimPayment, ClaimPayment,
@@ -29,7 +31,7 @@ import type {
Payer835, Payer835,
Payee835, Payee835,
ReassociationTrace, ReassociationTrace,
BatchSummary, BatchSummary as ParserBatchSummary,
} from "@/types"; } from "@/types";
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? ""; const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
@@ -48,7 +50,7 @@ export type NdjsonProgressEvent =
| { type: "trace"; data: ReassociationTrace } | { type: "trace"; data: ReassociationTrace }
| { type: "payer"; data: Payer | Payer835 } | { type: "payer"; data: Payer | Payer835 }
| { type: "payee"; data: Payee835 } | { type: "payee"; data: Payee835 }
| { type: "summary"; data: BatchSummary }; | { type: "summary"; data: ParserBatchSummary };
export type ParseProgress = (event: NdjsonProgressEvent) => void; export type ParseProgress = (event: NdjsonProgressEvent) => void;
@@ -72,6 +74,69 @@ export interface HealthResponse {
version: 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;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Low-level helpers // Low-level helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -105,17 +170,29 @@ async function readErrorBody(res: Response): Promise<string> {
} }
} }
/** 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( async function parseNdjsonStream(
res: Response, res: Response,
onProgress: ParseProgress onProgress: ParseProgress
): Promise<BatchSummary> { ): Promise<ParserBatchSummary> {
if (!res.body) { if (!res.body) {
throw new Error("Response had no body to stream."); throw new Error("Response had no body to stream.");
} }
const reader = res.body.getReader(); const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8"); const decoder = new TextDecoder("utf-8");
let buffer = ""; let buffer = "";
let lastSummary: BatchSummary | null = null; let lastSummary: ParserBatchSummary | null = null;
// Read the body chunk-by-chunk. A `while (true)` loop is the standard // Read the body chunk-by-chunk. A `while (true)` loop is the standard
// pattern for ReadableStreamDefaultReader; `done` breaks us out. // pattern for ReadableStreamDefaultReader; `done` breaks us out.
@@ -143,7 +220,7 @@ async function parseNdjsonStream(
} }
onProgress(event as NdjsonProgressEvent); onProgress(event as NdjsonProgressEvent);
if (event.type === "summary") { if (event.type === "summary") {
lastSummary = event.data as BatchSummary; lastSummary = event.data as ParserBatchSummary;
} }
} }
nl = buffer.indexOf("\n"); nl = buffer.indexOf("\n");
@@ -156,7 +233,7 @@ async function parseNdjsonStream(
const event = JSON.parse(tail) as NdjsonEvent; const event = JSON.parse(tail) as NdjsonEvent;
onProgress(event as NdjsonProgressEvent); onProgress(event as NdjsonProgressEvent);
if (event.type === "summary") { if (event.type === "summary") {
lastSummary = event.data as BatchSummary; lastSummary = event.data as ParserBatchSummary;
} }
} }
@@ -167,13 +244,13 @@ async function parseNdjsonStream(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public surface // Public surface — parser endpoints
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function parse837( async function parse837(
file: File, file: File,
options: ParseOptions = {} options: ParseOptions = {}
): Promise<ParseResult837 | BatchSummary> { ): Promise<ParseResult837 | ParserBatchSummary> {
if (!isConfigured) throw notConfiguredError(); if (!isConfigured) throw notConfiguredError();
const { payer = "co_medicaid", strict = false, includeRawSegments = true, onProgress } = options; const { payer = "co_medicaid", strict = false, includeRawSegments = true, onProgress } = options;
@@ -209,7 +286,7 @@ async function parse837(
async function parse835( async function parse835(
file: File, file: File,
options: ParseOptions = {} options: ParseOptions = {}
): Promise<ParseResult835 | BatchSummary> { ): Promise<ParseResult835 | ParserBatchSummary> {
if (!isConfigured) throw notConfiguredError(); if (!isConfigured) throw notConfiguredError();
const { payer = "co_medicaid_835", strict = false, includeRawSegments = true, onProgress } = options; const { payer = "co_medicaid_835", strict = false, includeRawSegments = true, onProgress } = options;
@@ -255,19 +332,108 @@ async function health(): Promise<HealthResponse | null> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Legacy helpers (placeholder kept for the data adapter pattern). // Public surface — GET endpoints (sub-project 1)
// The existing pages use the `data` adapter below to either hit the API or // All throw `notConfiguredError` when no backend is wired. The hooks in
// read from the in-memory store. The legacy endpoints listed here are // `src/hooks/` are responsible for the in-memory zustand fallback.
// intentionally not implemented against the live backend (the backend only
// exposes the parser endpoints); they exist so the import surface doesn't
// break existing callers, but they always throw when invoked.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function legacyNotImplemented(name: string): Promise<never> { async function listBatches(limit?: number): Promise<BatchSummary[]> {
if (!isConfigured) throw notConfiguredError();
const params: Record<string, unknown> = {};
if (limit !== undefined) params.limit = limit;
const res = await fetch(joinUrl(`/api/batches${qs(params)}`), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error( throw new Error(
`api.${name} is not wired to the live backend. Use api.parse837 / api.parse835 instead, ` + `${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
`or read the in-memory sample store via the \`data\` adapter.`
); );
}
const body = (await res.json()) as { items: BatchSummary[] };
return body.items;
}
async function getBatch(id: string): Promise<ParseResult837 | ParseResult835> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(joinUrl(`/api/batches/${encodeURIComponent(id)}`), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as ParseResult837 | ParseResult835;
}
async function listClaims<T = unknown>(
params: ListClaimsParams
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/claims${qs(params as Record<string, unknown>)}`),
{ headers: { Accept: "application/json" } }
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as PaginatedResponse<T>;
}
async function listRemittances<T = unknown>(
params: ListRemittancesParams
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/remittances${qs(params as Record<string, unknown>)}`),
{ headers: { Accept: "application/json" } }
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as PaginatedResponse<T>;
}
async function listProviders<T = unknown>(
params: ListProvidersParams = {}
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/providers${qs(params as Record<string, unknown>)}`),
{ headers: { Accept: "application/json" } }
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as PaginatedResponse<T>;
}
async function listActivity<T = unknown>(
params: ListActivityParams = {}
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/activity${qs(params as Record<string, unknown>)}`),
{ headers: { Accept: "application/json" } }
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as PaginatedResponse<T>;
} }
export const api = { export const api = {
@@ -276,31 +442,10 @@ export const api = {
health, health,
parse837, parse837,
parse835, parse835,
listBatches,
// Legacy placeholders — preserved so existing imports compile but never getBatch,
// resolve against the live backend. The `data` adapter handles the listClaims,
// sample-vs-backend switch. listRemittances,
getClaims: () => legacyNotImplemented("getClaims"), listProviders,
createClaim: (_payload: unknown) => legacyNotImplemented("createClaim"), listActivity,
getRemittances: () => legacyNotImplemented("getRemittances"),
getProviders: () => legacyNotImplemented("getProviders"),
getActivity: () => legacyNotImplemented("getActivity"),
};
/**
* Thin adapter that prefers the backend when configured, otherwise
* resolves against the in-memory store. Pages should call this rather
* than touching the store directly so the swap is trivial later.
*
* Note: in the current backend the GET endpoints aren't implemented, so
* these all fall back to the in-memory store regardless. The structure is
* kept for forward-compat.
*/
export const data = {
claims: () => (api.isConfigured ? api.getClaims() : useAppStore.getState().claims),
remittances: () =>
api.isConfigured ? api.getRemittances() : useAppStore.getState().remittances,
providers: () =>
api.isConfigured ? api.getProviders() : useAppStore.getState().providers,
activity: () => (api.isConfigured ? api.getActivity() : useAppStore.getState().activity),
}; };
+1 -1
View File
@@ -14,5 +14,5 @@
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts", "vitest.config.ts"]
} }
+21
View File
@@ -0,0 +1,21 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
test: {
// Node 18+ exposes `Response` / `fetch` globals, so we don't need jsdom.
// VITE_API_BASE_URL must be set so the api module treats itself as
// configured; otherwise every GET would throw notConfiguredError before
// reaching the mocked fetch.
env: {
VITE_API_BASE_URL: "http://test.local",
},
},
});