/** * X12 file-kind auto-detection helper. * * SP35: the Upload page used to default to "837p" silently — dropping an 835 * file on the page while the dropdown still said "837p" routed the file to * /api/parse-837, which silently persisted an empty batch. Layer A of the * defense-in-depth fix is to detect the kind from the file's first few KB * and switch the dropdown automatically before the user clicks Parse. * * Layer B is the server-side envelope check (cyclone.api._transaction_set_id_from_segments). * This helper is purely advisory: if it returns null, the UI keeps whatever * the user picked and the server guard rejects with a clean 400 if it's wrong. * * Detection is intentionally cheap: read the first ~4KB and look for the ST * segment (or the bare TA1 segment, which has no ST envelope). The full file * is never loaded — X12 envelopes are always within the first ISA segment. */ import type { ParsedBatchKind } from "@/types"; /** Number of bytes to read from the start of the file for detection. */ const DETECT_BYTES = 4096; export type DetectedKind = ParsedBatchKind | "999" | "277ca" | "ta1" | "unknown"; export function detectKindFromText(text: string): DetectedKind { // Strip whitespace and look for the ST segment. // X12 envelopes are always within the first segment, so we don't need // to scan far. The segment terminator is `~` (per the ISA segment, // which we also don't need to parse here — we only care which kind of // payload follows). // // We do a simple substring search rather than full tokenization because: // - Detection must run before the user even knows if their file is // valid X12. If we ran `tokenize()` and the file has a malformed // ISA, we'd throw and the UI couldn't show a helpful message. // - A simple prefix match on `ST**` is enough to disambiguate // every kind the Upload page might receive. False positives are // caught by the server-side guard. const upper = text.toUpperCase(); // 277CA uses ST*277* or ST*277CA* (per parse_277ca.py line 13 comment). // Check 277CA before 277 because ST*277CA contains "277". if (upper.includes("ST*277CA*")) return "277ca"; if (upper.includes("ST*277*")) return "277ca"; // 837P accepts ST*837* or ST*837P* (the trailing P is the professional // claim qualifier, but the ISA envelope alone tells you it's a // professional file). Check 837P before 837 for the same reason. if (upper.includes("ST*837P*")) return "837p"; if (upper.includes("ST*837*")) return "837p"; // 835 has ST*835* — no other qualifier in common use. if (upper.includes("ST*835*")) return "835"; // 999 ACK has ST*999* — no qualifier. if (upper.includes("ST*999*")) return "999"; // TA1 has no ST envelope. The interchange-ack segment is the bare TA1* // immediately after ISA/IEA. Match the segment header. if (/\bTA1\*/.test(upper)) return "ta1"; return "unknown"; } /** * Browser-side helper: read the first DETECT_BYTES from a File and detect * its kind. Returns a Promise so it composes naturally with FileReader / * Blob.slice. Returns "unknown" on read error so the caller can keep the * user's current selection and let the server guard surface a clean 400. */ export async function detectKindFromFile(file: File): Promise { try { const blob = file.slice(0, DETECT_BYTES); const text = await blob.text(); return detectKindFromText(text); } catch { return "unknown"; } } /** * Map a DetectedKind to the ParsedBatchKind the Upload dropdown supports. * Returns null for kinds the Upload page can't ingest (999, 277CA, TA1) — * the UI then surfaces a "this file isn't supported here" hint instead of * silently misrouting it. The server-side guards in cyclone.api still * catch any escape. */ export function detectedKindToParsedBatchKind(kind: DetectedKind): ParsedBatchKind | null { switch (kind) { case "837p": return "837p"; case "835": return "835"; default: return null; } }