feat(sp35): Upload page auto-detects 837P vs 835 from file content
Layer A of the SP35 defense-in-depth fix. Before SP35 the dropdown
silently defaulted to '837p' and never changed when a file was dropped
on the page — uploading an 835 file routed it to /api/parse-837 which
(prior to SP35 Task 2) silently persisted an empty batch.
The change:
1. New pure helper src/lib/x12-detect.ts:
- detectKindFromText(text) reads the first ~4KB and returns the
DetectedKind ('837p' | '835' | '999' | '277ca' | 'ta1' | 'unknown')
by matching the ST01 segment (or the bare TA1 segment for the
no-ST envelope). Cheap substring scan; never invokes tokenize().
- detectKindFromFile(file) is the File-aware wrapper used by the UI.
- detectedKindToParsedBatchKind maps the DetectedKind to the kind
the Upload dropdown supports. Returns null for 999/277CA/TA1 so
the UI can surface a clean 'this file isn't supported here' hint.
2. Upload.tsx: pickFile is now async and reads the file before storing
it. If the detected kind differs from the dropdown's current value,
it switches the dropdown and toasts a hint. If the detected kind is
999/277CA/TA1 (Upload doesn't ingest those), it shows an error toast.
20 new tests in src/lib/x12-detect.test.ts cover the 6 DetectedKind
paths, the File wrapper, case-insensitivity, garbage input, the
ST*8370 false-positive guard, and the detectedKindToParsedBatchKind
mapping.
This commit is contained in:
+33
-1
@@ -33,6 +33,10 @@ import { StatPill, ValidationDot } from "@/components/ClaimCard/shared";
|
||||
import { api, ApiError, type BatchSummary, type ParseProgress } from "@/lib/api";
|
||||
import { downloadBlob } from "@/lib/download";
|
||||
import { fmt, toNum } from "@/lib/format";
|
||||
import {
|
||||
detectKindFromFile,
|
||||
detectedKindToParsedBatchKind,
|
||||
} from "@/lib/x12-detect";
|
||||
import { useAppStore } from "@/store";
|
||||
import { useParse } from "@/hooks/useParse";
|
||||
import { useBatchExport } from "@/hooks/useBatchExport";
|
||||
@@ -437,9 +441,37 @@ export function Upload() {
|
||||
// hook call above. Keeping the comment as a breadcrumb for future
|
||||
// readers who grep "Auto-select".)
|
||||
|
||||
function pickFile(f: File | null) {
|
||||
async function pickFile(f: File | null) {
|
||||
setFile(f);
|
||||
setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 });
|
||||
if (!f) return;
|
||||
// SP35: auto-detect the kind from the file's first few KB and switch
|
||||
// the dropdown before the user hits Parse. This is layer A of the
|
||||
// defense-in-depth fix (layer B is the server-side envelope guard
|
||||
// in cyclone.api). Before SP35 the dropdown defaulted to "837p"
|
||||
// silently — dropping an 835 file on the page routed it to
|
||||
// /api/parse-837 and produced a bogus empty batch. Detection runs
|
||||
// asynchronously on a 4KB slice; if it can't determine the kind
|
||||
// (unknown file, read error) we keep the user's current selection
|
||||
// and the server guard will surface a clean 400 if it's wrong.
|
||||
const detected = await detectKindFromFile(f);
|
||||
const matched = detectedKindToParsedBatchKind(detected);
|
||||
if (matched && matched !== kind) {
|
||||
setKind(matched);
|
||||
setPayer(matched === "837p" ? PAYERS_837[0]!.value : PAYERS_835[0]!.value);
|
||||
toast.message(
|
||||
`Detected ${matched === "837p" ? "837P" : "835"} file — switched the dropdown.`,
|
||||
{ description: f.name },
|
||||
);
|
||||
} else if (detected === "999" || detected === "277ca" || detected === "ta1") {
|
||||
// The Upload page only supports 837P and 835; for the other X12
|
||||
// kinds (which have their own endpoints), surface a hint instead
|
||||
// of silently leaving the dropdown on whatever the user picked.
|
||||
toast.error(
|
||||
`Detected ${detected.toUpperCase()} file — the Upload page only accepts 837P or 835.`,
|
||||
{ description: "Use the matching endpoint from the History tab or the API." },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function onParse() {
|
||||
|
||||
Reference in New Issue
Block a user