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:
Nora
2026-07-06 09:58:26 -06:00
parent b0e06a2dd0
commit 3bc5740e8b
3 changed files with 279 additions and 1 deletions
+33 -1
View File
@@ -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() {