35 Commits

Author SHA1 Message Date
Nora 5bb9588f09 feat(auth): RoleGate component 2026-06-22 15:34:19 -06:00
Nora d895854dcc feat(auth): AuthProvider context + useAuth hook 2026-06-22 15:34:18 -06:00
Nora 55a298f05f feat(auth): fetch wrapper with 401 redirect + authApi 2026-06-22 15:34:18 -06:00
Nora 0aea0f64ac feat(types): add User type 2026-06-22 15:34:18 -06:00
Nora 6c80bf0512 feat(auth): CLI users subcommand 2026-06-22 15:34:18 -06:00
Nora 9c57b493a7 feat(audit): record user_id on log events 2026-06-22 15:34:18 -06:00
Nora 609499543e test(auth): login rate limit 2026-06-22 15:34:05 -06:00
Nora 768f7c6247 feat(auth): bootstrap first admin from env vars 2026-06-22 15:34:05 -06:00
Nora 86b635104c feat(auth): get_current_user + login/logout/me + admin user management 2026-06-22 15:34:05 -06:00
Nora e158871a9a feat(auth): sessions module + permissions matrix + rate limiter
SQLite drops tzinfo on DateTime roundtrip — normalize on read/write
so callers see tz-aware datetimes.
2026-06-22 15:34:05 -06:00
Nora 1ca50e2bc0 test(auth): tighten duplicate-username test + add update_password test 2026-06-22 15:32:56 -06:00
Nora 74d7056284 feat(auth): users module with bcrypt hashing + CRUD 2026-06-22 15:32:56 -06:00
Nora 0ba91040f1 feat(migration): 0010 users + sessions, 0011 audit_log.user_id 2026-06-22 15:32:56 -06:00
Nora dc83d7bef2 feat(db): add User and Session models 2026-06-22 15:32:56 -06:00
Nora 9d4798a124 feat(deps): add passlib[bcrypt] for password hashing
passlib 1.7.4 + bcrypt >= 4.1 are incompatible (passlib probes
bcrypt.__about__ which 4.x removed). Pin bcrypt<4.1.
2026-06-22 15:32:56 -06:00
Nora d552d3d3ec docs(plan): auth implementation plan — 29 tasks, ~40 files 2026-06-22 15:32:45 -06:00
Nora 42a826cb73 docs(spec): auth (admin/user/viewer) design — sessions, RBAC, login flow 2026-06-22 15:32:45 -06:00
Tyler 05b43078b9 chore(deps): consolidate dev deps into [project.optional-dependencies] dev
Follow-up to 7c1be58. pytest-randomly was added to a PEP 735
[dependency-groups] dev block by 'uv add --dev', leaving the
project with two 'dev' groups (the legacy [project.optional-
dependencies] dev and the new [dependency-groups] dev). To get
the full dev env someone had to run 'uv sync --extra dev --group
dev', which is easy to miss.

This commit moves pytest-randomly>=4.1 into the
[project.optional-dependencies] dev list (where pytest,
pytest-cov, pytest-asyncio, and httpx already live) and drops
the [dependency-groups] block. 'uv sync --extra dev' now
installs the full dev toolchain. uv.lock updated accordingly:
the [package.dev-dependencies] and [package.metadata.requires-
dev] sections are gone, and pytest-randomly is part of the
standard dev extra.
2026-06-22 11:44:39 -06:00
Tyler 7c1be58860 chore(deps): add cryptography dep, pytest-randomly dev dep
Two follow-ups to the v0.2.0 release:

1. Add cryptography>=49.0,<50 to the [project.dependencies] list.
   cyclone.backup / cyclone.backup_service import it at module top
   level, so it has to be a hard dep — not an extra — or the test
   suite fails to collect on a fresh 'uv sync'. A clean install
   (without this commit) gets 28 collection errors with no tracked
   file changes. This was latent because the original 99M venv
   had cryptography installed out-of-band and masked the missing
   dep declaration.

2. Add pytest-randomly>=4.1.0 to the dev dep group.
   Used to characterize the test suite's order-dependence: under
   alphabetic order the suite shows 7 failures; under seed=12345
   it shows 34. That variance is evidence of test-order state
   interactions, not venv issues. Keeping pytest-randomly in
   the dev deps so this can be re-verified on demand.

Note: pytest-randomly was added with 'uv add --dev', which uses
the PEP 735 [dependency-groups] syntax. pyproject.toml now has
two 'dev' groups — the legacy [project.optional-dependencies] dev
= [pytest, pytest-cov, pytest-asyncio, httpx] and the new
[dependency-groups] dev = [pytest-randomly]. To get the full dev
env, run: uv sync --extra dev --group dev. Worth consolidating
in a follow-up.
2026-06-22 11:43:32 -06:00
Tyler 9bca4b608a feat(release): v0.2.0 — batch 837 export, ClaimCard, theme tokens
Backend:

- New POST /api/batches/{id}/export-837: regenerate X12 837 files
  for a list of claim_ids into a ZIP using HCPF file naming standards,
  with a unique interchange/group control number per export. Wire
  the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
  (NM1*40) blocks so the serializer no longer falls back to
  CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
  batch_id in both JSON and NDJSON response shapes so the frontend
  can hit batch-scoped endpoints without an extra listBatches
  round-trip.
- Filename helpers and the 837 serializer updated to match the new
  HCPF envelope; tests cover batch export, parse batch_id, and the
  serializer's control-number uniqueness guarantee.

Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
  EditorialNote, ExportBar, TickerTape, and a charts/ set
  (BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
  the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
  colors to Tailwind theme tokens (bg-card, text-foreground,
  border/60, etc.) for consistency with the rest of the instrument
  chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
  reworked to compose the new shared components and consume the new
  batch-scoped API surface (notably ExportBar wired into Batches).

Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
  components and the useBatchExport hook.

Rolls up into the v0.2.0 release tag.
2026-06-22 11:01:58 -06:00
cyclone 35298907bc docs(spec): add Ubuntu Docker deployment design
Production deployment of Cyclone on a single Ubuntu Linux server:
- Two-container Docker Compose (backend + nginx frontend)
- App-layer auth: username/password, argon2id, session cookies
- 3 fixed roles: admin / operator / viewer with documented action matrix
- SQLCipher encryption at rest, key as Docker secret
- Existing BackupService autostarts (24h interval, 14-day retention)
- LAN-only bind, single operator, VPN for outside access
- Manual update via 'docker compose pull', env-var rollback
- SFTP stays stub (download ZIP + MFT UI); listed as v2

Builds on the parent 2026-06-19 production-readiness spec which
shipped local-only in-memory store + react-query wiring.
2026-06-22 10:43:26 -06:00
Tyler 22720168c4 test(claims): wrap page in DrillStackProvider, switch to DrillDrawerHeader testids
Phase 5 Task 5.8 (PartiesGrid) and 5.9 (ValidationPanel) added useDrillStack()
calls, so Claims.test.tsx needs a DrillStackProvider wrapper around the
page render to keep the hook happy. Task 5.10 refactored the ClaimDrawer
header onto the shared DrillDrawerHeader, so the deep-link and close-button
tests need to find the title via <h2> and the close button via
aria-label='Close drawer' instead of the now-removed header-id /
header-close testids.

Without this fix the page-level Claims tests report 3 failures
(deep link, URL clear after close, ?-mark with drawer closed) that
were not failing on the Phase 4 base (9a313d2).
2026-06-21 18:10:43 -06:00
Tyler 1c0d855b8e refactor(claim-drawer): mount on shared DrillDrawerHeader shell 2026-06-21 18:10:43 -06:00
Tyler 8db5db7610 feat(claim-drawer): validation rule opens peek with rule catalog 2026-06-21 18:10:43 -06:00
Tyler 786ead8c94 feat(claim-drawer): payer name opens PeekModal on top of drawer 2026-06-21 18:10:43 -06:00
Tyler 6c773c1159 feat(upload): streamed claim cards offer drill to persisted entity 2026-06-21 18:10:43 -06:00
Tyler 5c7e9b6168 feat(batch-diff): claim ids drillable to /claims?claim=ID 2026-06-21 18:10:43 -06:00
Tyler ac87ed4908 feat(reconciliation): card body drillable, select button split 2026-06-21 18:10:43 -06:00
Tyler 4a8ce1a524 feat(inbox): rejected + payer_rejected + done_today rows drillable 2026-06-21 18:10:43 -06:00
Tyler 5053a1ea8e feat(acks): row click opens AckDrawer 2026-06-21 18:10:43 -06:00
Tyler 33fa899217 feat(drill): AckDrawer with header + segment status list 2026-06-21 18:10:43 -06:00
Tyler 6fdbceefc2 feat(drill): useAckDrawerUrlState — ?ack= URL sync 2026-06-21 18:10:43 -06:00
Tyler 7290cac643 plan+spec: add migration 0014 to relax PKs; renumber plan Task 1.3 -> 1.4
The implementer caught that claims.id is a single-column PK, which
prevents the same CLM01 from existing in multiple batches. That
makes the spec's pre-flight 409 workflow unreachable and resubmits
impossible. Migration 0014 relaxes the PKs to composite (batch_id,
id) on both claims and remittances, and updates all FKs that
referenced the old single-column PK. Task 1.3 in the plan is now
the migration; the previous Task 1.3 (helper tightening) is
renumbered to Task 1.4 and will run after 0014 lands.
2026-06-21 18:09:06 -06:00
Tyler 5e8c7b11ea plan: parse-decide workflow implementation for 837/835 upload dedup 2026-06-21 17:53:21 -06:00
Tyler 9c0cec8f0c spec: parse-then-decide workflow for 837/835 upload dedup 2026-06-21 17:47:03 -06:00
112 changed files with 19973 additions and 4062 deletions
+233
View File
@@ -0,0 +1,233 @@
// UI/UX Score Loop — pass 1 driver.
// Loads each route at three sizes in Chrome Canary, captures screenshots,
// logs console errors, runs a small interaction probe per flow, and
// writes a JSON report. Does not modify any source files.
import puppeteer from "puppeteer-core";
import { mkdir, writeFile } from "node:fs/promises";
const BASE = "http://127.0.0.1:5173";
const SHOTS = "/tmp/cyclone-uiux/shots";
const REPORT = "/tmp/cyclone-uiux/report.json";
const SIZES = [
{ name: "desktop", w: 1440, h: 900 },
{ name: "tablet", w: 768, h: 1024 },
{ name: "mobile", w: 375, h: 812 },
];
// Routes to load. path = the route; name = the flow label; ready = a
// selector we wait for to consider the page "rendered".
const FLOWS = [
{ name: "dashboard", path: "/", ready: "aside nav, h1, h2" },
{ name: "upload", path: "/upload", ready: "section[aria-label='File upload']" },
{ name: "inbox", path: "/inbox", ready: "main, section[aria-label='Queue summary']" },
{ name: "claims", path: "/claims", ready: "table, [data-testid='claims-page-body']" },
{ name: "claims-denied", path: "/claims?status=denied", ready: "table, [data-testid='claims-page-body']" },
{ name: "remittances", path: "/remittances", ready: "main, table" },
{ name: "providers", path: "/providers", ready: "main, table" },
{ name: "reconciliation",path: "/reconciliation", ready: "main" },
{ name: "acks", path: "/acks", ready: "main, table" },
{ name: "batches", path: "/batches", ready: "main, table" },
{ name: "batch-diff", path: "/batch-diff", ready: "main" },
{ name: "activity", path: "/activity", ready: "main" },
{ name: "404", path: "/does-not-exist", ready: "main" },
];
async function setupViewports(browser) {
const pages = [];
for (const size of SIZES) {
const page = await browser.newPage();
await page.setViewport({ width: size.w, height: size.h, deviceScaleFactor: 1 });
pages.push({ page, size });
}
return pages;
}
async function probeFlow(page, flow) {
const consoleErrors = [];
const pageErrors = [];
const failedRequests = [];
const onConsole = (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
};
const onPageError = (err) => pageErrors.push(err.message);
const onRequestFailed = (req) => failedRequests.push(`${req.method()} ${req.url()} :: ${req.failure()?.errorText}`);
page.on("console", onConsole);
page.on("pageerror", onPageError);
page.on("requestfailed", onRequestFailed);
const t0 = Date.now();
let rendered = false;
let readyError = null;
try {
await page.goto(`${BASE}${flow.path}`, { waitUntil: "networkidle2", timeout: 15000 });
if (flow.ready) {
try {
await page.waitForSelector(flow.ready, { timeout: 5000 });
rendered = true;
} catch (e) {
readyError = e.message;
}
} else {
rendered = true;
}
} catch (e) {
readyError = e.message;
}
const loadMs = Date.now() - t0;
page.off("console", onConsole);
page.off("pageerror", onPageError);
page.off("requestfailed", onRequestFailed);
return { rendered, loadMs, readyError, consoleErrors, pageErrors, failedRequests };
}
async function probeInteractions(page, flow) {
const findings = [];
// Generic a11y / structural probes per flow.
try {
// Sidebar visible? (md+ shows it; < md hides it)
const aside = await page.$("aside");
findings.push({ check: "sidebar-present", pass: !!aside });
} catch (e) {
findings.push({ check: "sidebar-present", pass: false, err: e.message });
}
try {
// Top bar present?
const main = await page.$("main#main-content");
findings.push({ check: "main-present", pass: !!main });
} catch (e) {
findings.push({ check: "main-present", pass: false, err: e.message });
}
try {
// H1 or page heading?
const heading = await page.evaluate(() => {
const h = document.querySelector("h1, h2");
return h ? h.textContent?.trim().slice(0, 60) : null;
});
findings.push({ check: "heading-present", pass: !!heading, value: heading });
} catch (e) {
findings.push({ check: "heading-present", pass: false, err: e.message });
}
// Flow-specific probes.
if (flow.name === "claims" || flow.name === "claims-denied") {
try {
const chips = await page.$$("[role='radio'], button[role='radio']");
findings.push({ check: "status-chips", pass: chips.length >= 1, count: chips.length });
} catch (e) {
findings.push({ check: "status-chips", pass: false, err: e.message });
}
try {
const search = await page.$("input[placeholder*='Search']");
findings.push({ check: "search-input", pass: !!search });
} catch (e) {
findings.push({ check: "search-input", pass: false, err: e.message });
}
}
if (flow.name === "upload") {
try {
const dropzone = await page.$("section[aria-label='File upload']");
findings.push({ check: "dropzone-present", pass: !!dropzone });
const selects = await page.$$("button[role='combobox']");
findings.push({ check: "payer-kind-selects", pass: selects.length >= 2, count: selects.length });
} catch (e) {
findings.push({ check: "upload-elements", pass: false, err: e.message });
}
}
if (flow.name === "inbox") {
try {
const lanes = await page.$$("main > div > div");
findings.push({ check: "lane-cards", pass: lanes.length >= 1, count: lanes.length });
} catch (e) {
findings.push({ check: "lane-cards", pass: false, err: e.message });
}
}
if (flow.name === "404") {
try {
const text = await page.evaluate(() => document.body.innerText);
findings.push({ check: "404-text", pass: text.includes("404") || text.toLowerCase().includes("doesn't exist") });
} catch (e) {
findings.push({ check: "404-text", pass: false, err: e.message });
}
}
return findings;
}
async function main() {
await mkdir(SHOTS, { recursive: true });
const browser = await puppeteer.launch({
executablePath: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
headless: "new",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const startedAt = new Date().toISOString();
const results = [];
for (const size of SIZES) {
const page = await browser.newPage();
await page.setViewport({ width: size.w, height: size.h, deviceScaleFactor: 1 });
for (const flow of FLOWS) {
const probe = await probeFlow(page, flow);
const interactions = await probeInteractions(page, flow);
const shot = `${SHOTS}/${flow.name}--${size.name}.png`;
try {
await page.screenshot({ path: shot, fullPage: false });
} catch (e) {
// ignore — recording in result
}
results.push({
flow: flow.name,
path: flow.path,
size: size.name,
viewport: { w: size.w, h: size.h },
probe,
interactions,
shot,
});
console.log(
`${size.name.padEnd(7)} ${flow.name.padEnd(20)} ` +
`render=${probe.rendered} load=${probe.loadMs}ms ` +
`consoleErr=${probe.consoleErrors.length} pageErr=${probe.pageErrors.length}`
);
}
await page.close();
}
await browser.close();
// Aggregate.
const summary = {
startedAt,
endedAt: new Date().toISOString(),
flows: results.length,
sizes: SIZES.map((s) => s.name),
renderedOk: results.filter((r) => r.probe.rendered).length,
withConsoleErrors: results.filter((r) => r.probe.consoleErrors.length > 0).length,
withPageErrors: results.filter((r) => r.probe.pageErrors.length > 0).length,
withFailedRequests: results.filter((r) => r.probe.failedRequests.length > 0).length,
results,
};
await writeFile(REPORT, JSON.stringify(summary, null, 2));
console.log("\nSummary:", JSON.stringify({
flows: summary.flows,
renderedOk: summary.renderedOk,
withConsoleErrors: summary.withConsoleErrors,
withPageErrors: summary.withPageErrors,
withFailedRequests: summary.withFailedRequests,
}, null, 2));
console.log("\nReport:", REPORT);
console.log("Shots:", SHOTS);
}
main().catch((e) => {
console.error("FATAL", e);
process.exit(1);
});
+10
View File
@@ -16,6 +16,15 @@ dependencies = [
"sqlalchemy>=2.0,<3",
"pyyaml>=6.0,<7",
"keyring>=25.0,<26",
# backup_service / backup: encryption-at-rest (SP17). Used at module
# top-level by cyclone.backup, so it has to be a hard dep — not an
# extra — or the test suite fails to collect when the venv is built
# from a clean `uv sync`.
"cryptography>=49.0,<50",
# passlib 1.7.4 + bcrypt >= 4.1 are incompatible (passlib probes bcrypt.__about__
# which 4.x removed). Pin bcrypt < 4.1.
"passlib[bcrypt]>=1.7.4",
"bcrypt<4.1",
]
[project.optional-dependencies]
@@ -24,6 +33,7 @@ dev = [
"pytest-cov>=4.1",
"pytest-asyncio>=0.23,<1",
"httpx>=0.27,<1",
"pytest-randomly>=4.1",
]
sqlcipher = [
# SP12: encryption at rest. Optional — without it the DB is plain SQLite.
+8
View File
@@ -16,6 +16,14 @@ import sys
def main() -> None:
# Always run first-admin bootstrap before any other entry path.
# Must happen before ``serve`` (uvicorn) AND before the Click CLI
# dispatch — otherwise `python -m cyclone users create ...` on a
# fresh DB would race with the bootstrap's check, and the API
# could come up with zero users.
from cyclone.auth import bootstrap
bootstrap.run()
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
port = os.environ.get("CYCLONE_PORT", "8000")
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
+359 -12
View File
@@ -35,12 +35,27 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import ValidationError
from cyclone import __version__, db
from cyclone.db import Claim, ClaimState, Remittance
from cyclone.db import Batch, Claim, ClaimState, Remittance
from sqlalchemy import desc, or_
from sqlalchemy.exc import IntegrityError
from cyclone.inbox_state import apply_999_rejections
from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.audit_log import AuditEvent, append_event, verify_chain
def _actor_user_id(request: Request) -> int | None:
"""Return the acting user's id from ``request.state.user``, or None.
``get_current_user``/``matrix_gate`` populate ``request.state.user``
for both the authenticated path and the AUTH_DISABLED escape hatch.
Returns None when the state hasn't been set (e.g. background jobs
or unit tests that bypass auth). Used to stamp ``user_id`` onto
audit events without crashing the request.
"""
user = getattr(request.state, "user", None)
if user is None:
return None
return getattr(user, "id", None)
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
from cyclone.parsers.models_270 import (
@@ -273,6 +288,16 @@ app.include_router(ta1_acks.router)
app.include_router(admin.router)
@app.exception_handler(HTTPException)
async def _http_exc_handler(request, exc: HTTPException):
code = exc.detail if isinstance(exc.detail, str) else "error"
return JSONResponse(
status_code=exc.status_code,
content={"error": code, "detail": str(exc.detail)},
headers=exc.headers,
)
def _resolve_payer(name: str) -> PayerConfig:
if name not in PAYER_FACTORIES:
raise HTTPException(
@@ -420,11 +445,18 @@ async def parse_837(
ack_body = _build_and_persist_ack(rec.id)
if ack_body is not None:
body["ack"] = ack_body
# Surface the server-side batch id so the frontend can call
# /api/batches/{id}/export-837 (and any other batch-scoped
# endpoint) without a separate listBatches round-trip.
body["batch_id"] = rec.id
return JSONResponse(content=body)
# Default: NDJSON stream.
# Default: NDJSON stream. Pass the server-side batch id so the
# streaming client (the React Upload page) can call batch-scoped
# endpoints like /api/batches/{id}/export-837 without a separate
# GET /api/batches round-trip.
return StreamingResponse(
_ndjson_stream(result),
_ndjson_stream(result, batch_id=rec.id),
media_type="application/x-ndjson",
)
@@ -606,9 +638,12 @@ async def parse_835_endpoint(
body["reconciliation"] = _reconciliation_summary_for_batch(rec.id)
return JSONResponse(content=body)
# Default: NDJSON stream.
# Default: NDJSON stream. Pass the server-side batch id so the
# streaming client can call batch-scoped endpoints without a
# separate GET /api/batches round-trip (see /api/parse-837 for the
# parallel change).
return StreamingResponse(
_ndjson_stream_835(result),
_ndjson_stream_835(result, batch_id=rec.id),
media_type="application/x-ndjson",
)
@@ -746,6 +781,7 @@ async def parse_999_endpoint(
entity_id=cid,
payload={"source_batch_id": synthetic_id, "ack_id": row.id},
actor="999-parser",
user_id=_actor_user_id(request),
))
audit_s.commit()
@@ -969,6 +1005,7 @@ async def parse_277ca_endpoint(
"277ca_id": row.id,
},
actor="277ca-parser",
user_id=_actor_user_id(request),
))
audit_s.commit()
if apply_result.orphans:
@@ -1295,6 +1332,215 @@ def inbox_resubmit_rejected(
)
@app.post("/api/batches/{batch_id}/export-837")
def export_batch_837(request: Request, batch_id: str, body: dict):
"""Download a ZIP of regenerated X12 837 files for the requested claim_ids.
Body shape: ``{"claim_ids": [str, ...]}``.
Each successfully serialized claim becomes an entry in the ZIP named
per the HCPF X12 File Naming Standards:
``tp{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (with a per-claim
millisecond offset so every file in the bundle has a unique name).
The ``serialize_837_for_resubmit`` serializer is used so every file
gets a unique interchange / group control number — back-to-back
exports of the same set must produce different envelopes (required
by X12).
The submitter block (Loop 1000A — NM1*41 + PER) is populated from
the clearhouse singleton (dzinesco's identity in the seeded config)
and the receiver block (NM1*40) is populated from the per-payer
config. Without this wiring, the serializer falls back to
``CYCLONE`` / ``RECEIVER`` placeholders and HCPF rejects the file.
No DB state is mutated by this endpoint — it is read-only. Compare
with ``/api/inbox/rejected/resubmit?download=true`` which ALSO flips
``ClaimState.REJECTED → SUBMITTED``; the two endpoints are
intentionally separate.
Responses:
200 — ``application/zip`` with the .x12 entries. Per-claim failures
are surfaced via the ``X-Cyclone-Serialize-Errors`` header
(JSON-encoded array of ``{claim_id, reason}``).
400 — ``claim_ids`` missing or empty.
404 — ``batch_id`` unknown.
422 — every claim failed to serialize; body is JSON listing all
failures (``{"detail": {"serialize_errors": [...]}}``).
"""
import zipfile
from datetime import datetime
from zoneinfo import ZoneInfo
from cyclone.edi.filenames import build_outbound_filename
ids = body.get("claim_ids") or []
if not ids:
raise HTTPException(400, "claim_ids required")
with db.SessionLocal()() as s:
batch = s.get(Batch, batch_id)
if batch is None:
raise HTTPException(404, f"unknown batch: {batch_id}")
serialize_errors: list[dict] = []
ordered_rows: list[tuple[str, "Claim"]] = []
for cid in ids:
c = s.get(Claim, cid)
if c is None:
serialize_errors.append({"claim_id": cid, "reason": "unknown claim_id"})
continue
ordered_rows.append((cid, c))
# Pull clearhouse identity (submitter). If unseeded, the serializer
# falls back to placeholder defaults — degraded but not a hard error.
ch = store.get_clearhouse()
submitter_kwargs: dict = {}
if ch is not None:
submitter_kwargs = {
"sender_id": ch.tpid,
"submitter_name": ch.submitter_name,
"submitter_contact_name": ch.submitter_contact_name,
"submitter_contact_email": ch.submitter_contact_email,
}
# Submitter phone is not in the clearhouse config today, but if
# it ever is, wire it here. Email is the canonical contact
# channel for HCPF submissions per the SP9 spec.
if getattr(ch, "submitter_contact_phone", None):
submitter_kwargs["submitter_contact_phone"] = ch.submitter_contact_phone
# Resolve per-claim payer config so each file's receiver (NM1*40)
# and SBR09 are correct. Cache so we don't re-query the same payer.
from cyclone.db import PayerConfigORM as _PayerConfigORM
_payer_cache: dict[str, dict | None] = {}
def _resolve_payer_cfg(claim_obj: ClaimOutput) -> dict | None:
pid = (claim_obj.payer.id or "").strip() if claim_obj.payer else ""
pname = (claim_obj.payer.name or "").strip() if claim_obj.payer else ""
cache_key = pid or pname
if cache_key in _payer_cache:
return _payer_cache[cache_key]
cfg: dict | None = None
with db.SessionLocal()() as ss:
# 1. Exact match on (payer_id, "837P")
if pid:
row = ss.get(_PayerConfigORM, (pid, "837P"))
if row is not None:
cfg = dict(row.config_json)
# 2. Fallback: any row whose payer_id matches the parsed payer.name
# (HCPF files emit "SKCO0" in NM109 but the canonical
# payer_id in the DB is "CO_TXIX" — name-matching is the
# pragmatic lookup for that case).
if cfg is None and pname:
row = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.all()
)
for r in row:
cj = dict(r.config_json)
if cj.get("submitter_name") and pname.lower() in str(cj).lower():
cfg = cj
break
if (r.payer_id or "").upper() == pname.upper():
cfg = cj
break
# 3. Last resort: first 837P row in the table.
if cfg is None:
row = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.first()
)
if row is not None:
cfg = dict(row.config_json)
_payer_cache[cache_key] = cfg
return cfg
# Build per-claim kwargs (receiver + SBR09) lazily. Receiver
# defaults to the parsed payer name/ID if no config row matches.
def _serialize_kwargs(claim_obj: ClaimOutput) -> dict:
payer_cfg = _resolve_payer_cfg(claim_obj) or {}
receiver_id = (
payer_cfg.get("receiver_id")
or (claim_obj.payer.id if claim_obj.payer else None)
or "RECEIVER"
)
receiver_name = (
payer_cfg.get("receiver_name")
or (claim_obj.payer.name if claim_obj.payer else None)
or receiver_id
)
sbr09 = payer_cfg.get("sbr09_default") or "MC"
return {
"receiver_id": receiver_id,
"receiver_name": receiver_name,
"claim_filing_indicator_code": sbr09,
}
# Base MT timestamp for HCPF filenames. We add a per-claim
# millisecond offset so each file in the ZIP has a unique 17-digit
# ts (HCPF requires that; the spec also enforces "1of1" for the
# sequence element).
base_ts = datetime.now(ZoneInfo("America/Denver"))
def _per_claim_filename(idx: int, cid: str) -> str:
if ch is None:
# No clearhouse — fall back to a per-claim friendly name.
return f"claim-{cid}.x12"
# Millisecond offset, with second/minute rollover.
offset_ms = (idx - 1) * 1 # 1 ms per claim is enough within an export
ts_mt = base_ts.fromtimestamp(
base_ts.timestamp() + offset_ms / 1000.0, tz=ZoneInfo("America/Denver")
)
return build_outbound_filename(ch.tpid, "837P", now_mt=ts_mt)
buf = io.BytesIO()
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
for idx, (cid, c) in enumerate(ordered_rows, start=1):
if not c.raw_json:
serialize_errors.append({"claim_id": cid, "reason": "no raw_json"})
continue
try:
claim_obj = ClaimOutput.model_validate(c.raw_json)
except Exception as exc:
serialize_errors.append(
{"claim_id": cid, "reason": f"raw_json invalid: {exc}"}
)
continue
try:
kwargs = {**submitter_kwargs, **_serialize_kwargs(claim_obj)}
text = serialize_837_for_resubmit(
claim_obj, interchange_index=idx, **kwargs
)
except SerializeError837 as exc:
serialize_errors.append({"claim_id": cid, "reason": str(exc)})
continue
zf.writestr(_per_claim_filename(idx, cid), text)
success_count = len(ids) - len(serialize_errors)
if serialize_errors and success_count == 0:
# Every claim failed — surface the failure list in the body so the
# UI can render a useful error toast (the response is not a ZIP).
raise HTTPException(
422,
detail={"serialize_errors": serialize_errors},
)
buf.seek(0)
headers = {
"Content-Disposition": (
f'attachment; filename="batch-{batch_id}-{success_count}-claims.zip"'
),
}
if serialize_errors:
headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors)
return Response(
content=buf.getvalue(),
media_type="application/zip",
headers=headers,
)
@app.get("/api/inbox/export.csv")
def inbox_export_csv(lane: str):
"""Stream a CSV for a single lane."""
@@ -2297,7 +2543,7 @@ def get_clearhouse():
@app.post("/api/clearhouse/submit")
def submit_to_clearhouse(body: dict):
def submit_to_clearhouse(request: Request, body: dict):
"""Submit a batch of claims to the clearhouse (SFTP). SP9: stub.
Body: ``{"claim_ids": [...], "payer_id": "CO_TXIX"}``
@@ -2321,6 +2567,51 @@ def submit_to_clearhouse(body: dict):
if ch is None:
raise HTTPException(status_code=500, detail="clearhouse not seeded")
# Submitter (Loop 1000A) comes from the clearhouse config. The
# receiver (NM1*40) and SBR09 come from the per-payer config and
# are resolved per-claim below. Without this wiring, the
# serializer would emit "CYCLONE" / "RECEIVER" placeholders and
# the file would be rejected by HCPF.
submitter_kwargs = {
"sender_id": ch.tpid,
"submitter_name": ch.submitter_name,
"submitter_contact_name": ch.submitter_contact_name,
"submitter_contact_email": ch.submitter_contact_email,
}
if getattr(ch, "submitter_contact_phone", None):
submitter_kwargs["submitter_contact_phone"] = ch.submitter_contact_phone
# Build a payer_id → PayerConfig837 map once so we can look up the
# receiver + SBR09 default for each claim.
from cyclone.db import PayerConfigORM as _PayerConfigORM
def _resolve_payer_cfg(claim_obj) -> dict | None:
pid = (claim_obj.payer.id or "").strip() if claim_obj.payer else ""
pname = (claim_obj.payer.name or "").strip() if claim_obj.payer else ""
with db.SessionLocal()() as ss:
if pid:
row = ss.get(_PayerConfigORM, (pid, "837P"))
if row is not None:
return dict(row.config_json)
if pname:
rows = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.all()
)
for r in rows:
cj = dict(r.config_json)
if (r.payer_id or "").upper() == pname.upper():
return cj
if pname.lower() in str(cj).lower():
return cj
row = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.first()
)
return dict(row.config_json) if row else None
client = make_client(ch.sftp_block)
results = []
for cid in claim_ids:
@@ -2329,6 +2620,34 @@ def submit_to_clearhouse(body: dict):
except Exception as exc: # noqa: BLE001
results.append({"claim_id": cid, "ok": False, "error": str(exc)})
continue
# Re-resolve the claim so we can look up its payer config. We
# re-parse the stored x12_text to get a ClaimOutput (same path
# the serializer uses).
try:
from cyclone.parsers.parse_837 import parse as _parse837
claim_row_obj = _load_claim_row(cid)
if claim_row_obj is None or not (claim_row_obj.raw_json or {}).get("x12_text"):
raise RuntimeError("no stored x12_text for claim")
parsed = _parse837(claim_row_obj.raw_json["x12_text"])
claim_obj = parsed.claims[0] if parsed.claims else None
except Exception:
claim_obj = None
if claim_obj is not None:
payer_cfg = _resolve_payer_cfg(claim_obj) or {}
receiver_id = payer_cfg.get("receiver_id") or (claim_obj.payer.id if claim_obj.payer else None) or "RECEIVER"
receiver_name = payer_cfg.get("receiver_name") or (claim_obj.payer.name if claim_obj.payer else None) or receiver_id
sbr09 = payer_cfg.get("sbr09_default") or "MC"
# Re-serialize with the proper envelope values.
try:
x12_text = _serialize_claim_for_submit(
cid,
**{**submitter_kwargs, "receiver_id": receiver_id,
"receiver_name": receiver_name,
"claim_filing_indicator_code": sbr09},
)
except Exception as exc: # noqa: BLE001
results.append({"claim_id": cid, "ok": False, "error": str(exc)})
continue
filename = build_outbound_filename(ch.tpid, "837P")
remote = f"{ch.sftp_block.paths['outbound']}/{filename}"
staging_path = client.write_file(remote, x12_text.encode("utf-8"))
@@ -2352,14 +2671,26 @@ def submit_to_clearhouse(body: dict):
"stub": ch.sftp_block.stub,
},
actor="clearhouse-submit",
user_id=_actor_user_id(request),
))
audit_s.commit()
return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub}
def _serialize_claim_for_submit(claim_id: str) -> str:
def _load_claim_row(claim_id: str):
"""Helper: load a Claim row by id (or return None)."""
with db.SessionLocal()() as s:
return s.get(Claim, claim_id)
def _serialize_claim_for_submit(claim_id: str, **kwargs) -> str:
"""Serialize a claim to X12 for SFTP submission. Lazy import of the
serializer to avoid pulling FastAPI machinery at module import time."""
serializer to avoid pulling FastAPI machinery at module import time.
Optional ``**kwargs`` are forwarded to the serializer — used to
pass through clearhouse submitter info and per-payer receiver info
so the regenerated file matches what the HCPF MFT expects.
"""
from cyclone.parsers.serialize_837 import serialize_837
from cyclone import db
with db.SessionLocal()() as s:
@@ -2371,15 +2702,17 @@ def _serialize_claim_for_submit(claim_id: str) -> str:
from cyclone.parsers.parse_837 import parse
raw = row.raw_json or {}
# Reconstruct minimal ClaimOutput from raw_json; this is best-effort.
return _serialize_claim_from_raw(row, raw)
return _serialize_claim_from_raw(row, raw, **kwargs)
def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
def _serialize_claim_from_raw(claim_row, raw: dict, **kwargs) -> str:
"""Best-effort serializer that uses the stored raw_json to emit a fresh 837.
For SP9 this delegates to the existing serialize_837 helper if the
claim has a complete raw_segments array. Otherwise it returns a
minimal placeholder.
minimal placeholder. ``**kwargs`` are forwarded to the serializer
so callers can pass through submitter / receiver / SBR09 values
from the clearhouse and per-payer configs.
"""
from cyclone.parsers.serialize_837 import serialize_837
from cyclone.parsers.parse_837 import parse
@@ -2389,7 +2722,7 @@ def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
if isinstance(raw, dict) and raw.get("x12_text"):
result = parse(raw["x12_text"])
if result.claims:
return serialize_837(result.claims[0])
return serialize_837(result.claims[0], **kwargs)
# Fallback: raise so the caller sees an error.
raise RuntimeError(
f"claim {claim_row.id!r} cannot be re-serialized: no stored x12_text"
@@ -3240,4 +3573,18 @@ def reload_config():
return {"ok": True, "loaded": len(configs), "errors": []}
# --------------------------------------------------------------------------- #
# Auth routers (login/logout/me + admin user management) #
# --------------------------------------------------------------------------- #
from cyclone.auth.routes import router as auth_router
from cyclone.auth.admin import router as admin_users_router
app.include_router(auth_router)
app.include_router(admin_users_router)
__all__ = ["app"]
+30 -6
View File
@@ -93,19 +93,40 @@ def ndjson_stream_list(
}) + "\n"
def ndjson_stream_837(result: ParseResult) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → claims → summary."""
def ndjson_stream_837(
result: ParseResult, batch_id: str | None = None,
) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → claims → summary.
The ``batch_id`` is the server-side UUID assigned by the persistence
layer when the batch is ingested; the JSON response path exposes it
as the top-level ``batch_id`` field, but the NDJSON stream needs it
inline on the summary event so streaming clients can call
batch-scoped endpoints (``/api/batches/{id}/export-837``, …) without
a separate ``GET /api/batches`` round-trip. When ``batch_id`` is not
supplied the summary omits the field, preserving backward compat
with clients that don't expect it.
"""
envelope_obj = (
result.envelope.model_dump() if result.envelope is not None else None
)
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
for claim in result.claims:
yield (json.dumps({"type": "claim", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
summary_data = json.loads(result.summary.model_dump_json())
if batch_id is not None:
summary_data["batch_id"] = batch_id
yield (json.dumps({"type": "summary", "data": summary_data}) + "\n").encode("utf-8")
def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary."""
def ndjson_stream_835(
result: ParseResult835, batch_id: str | None = None,
) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary.
See ``ndjson_stream_837`` for why the optional ``batch_id`` is
merged into the summary event.
"""
yield (json.dumps({"type": "envelope", "data": json.loads(result.envelope.model_dump_json())}) + "\n").encode("utf-8")
yield (json.dumps({"type": "financial_info", "data": json.loads(result.financial_info.model_dump_json())}) + "\n").encode("utf-8")
yield (json.dumps({"type": "trace", "data": json.loads(result.trace.model_dump_json())}) + "\n").encode("utf-8")
@@ -113,7 +134,10 @@ def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8")
for claim in result.claims:
yield (json.dumps({"type": "claim_payment", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
summary_data = json.loads(result.summary.model_dump_json())
if batch_id is not None:
summary_data["batch_id"] = batch_id
yield (json.dumps({"type": "summary", "data": summary_data}) + "\n").encode("utf-8")
def strict_rewrite_837(result: ParseResult) -> ParseResult:
+8
View File
@@ -103,6 +103,12 @@ class AuditEvent:
computed hash. Payload must be JSON-serializable; the audit_log
module handles the encoding so callers don't need to think about
canonical form.
``user_id`` is the authenticated actor for this event, when known
(e.g. a parse-999 call made by user 7). It's stored on the row but
is NOT part of the hash chain — the chain hashes only the fields
that existed pre-SP-auth so verify_chain stays compatible with
pre-auth rows.
"""
event_type: str
@@ -111,6 +117,7 @@ class AuditEvent:
payload: dict[str, Any] = field(default_factory=dict)
actor: str = "system"
created_at: datetime | None = None
user_id: int | None = None
def append_event(
@@ -155,6 +162,7 @@ def append_event(
created_at=created_at,
prev_hash=prev_hash,
hash=GENESIS_PREV_HASH, # placeholder; updated below
user_id=event.user_id,
)
session.add(row)
session.flush() # populate row.id
+1
View File
@@ -0,0 +1 @@
"""Auth module — users, sessions, permissions, routes, admin, rate_limit."""
+95
View File
@@ -0,0 +1,95 @@
"""Admin-only user management: GET/POST/PATCH/DELETE /api/admin/users."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from cyclone.auth import users
from cyclone.auth.deps import get_current_user
from cyclone.auth.permissions import Role
from cyclone.db import SessionLocal, User
router = APIRouter(prefix="/api/admin/users", tags=["admin"])
def _require_admin(user: dict = Depends(get_current_user)) -> dict:
if user.get("role") != Role.ADMIN.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return user
def _validate_role(role: str) -> None:
valid = {Role.ADMIN.value, Role.USER.value, Role.VIEWER.value}
if role not in valid:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"role must be one of {sorted(valid)}",
)
@router.get("")
def list_users(_admin=Depends(_require_admin)):
with SessionLocal()() as db:
all_users = db.query(User).all()
return [users.to_public(u) for u in all_users]
@router.post("", status_code=status.HTTP_201_CREATED)
def create_user(body: dict, _admin=Depends(_require_admin)):
username = (body.get("username") or "").strip()
password = body.get("password") or ""
role = body.get("role") or ""
if not username or len(username) < 3:
raise HTTPException(status_code=422, detail="username must be at least 3 chars")
if len(password) < 12:
raise HTTPException(status_code=422, detail="password must be at least 12 chars")
_validate_role(role)
with SessionLocal()() as db:
if users.get_by_username(db, username) is not None:
raise HTTPException(status_code=409, detail="username already exists")
u = users.create(db, username=username, password=password, role=role)
return users.to_public(u)
@router.patch("/{user_id}")
def patch_user(user_id: int, body: dict, admin=Depends(_require_admin)):
me = admin
if me.get("id") == user_id and body.get("role") and body["role"] != Role.ADMIN.value:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="cannot_demote_self",
)
with SessionLocal()() as db:
if body.get("role") is not None:
_validate_role(body["role"])
users.update_role(db, user_id, body["role"])
if body.get("password") is not None:
if len(body["password"]) < 12:
raise HTTPException(status_code=422, detail="password must be at least 12 chars")
users.update_password(db, user_id, body["password"])
if body.get("disabled") is True:
users.disable(db, user_id)
u = users.get(db, user_id)
if u is None:
raise HTTPException(status_code=404, detail="user not found")
return users.to_public(u)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int, admin=Depends(_require_admin)):
me = admin
if me.get("id") == user_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="cannot_delete_self",
)
with SessionLocal()() as db:
u = users.get(db, user_id)
if u is None:
raise HTTPException(status_code=404, detail="user not found")
users.disable(db, user_id)
return None
+70
View File
@@ -0,0 +1,70 @@
"""First-admin bootstrap: create the initial admin from env vars if no users exist.
Called from ``python -m cyclone`` before either ``cli.main()`` or
``uvicorn`` so users exist by the time the API serves requests.
Precedence:
1. ``CYCLONE_AUTH_DISABLED=1`` — dev escape hatch. Flip the
``cyclone.auth.deps.AUTH_DISABLED`` flag so the API returns a
synthetic admin user without checking credentials. Never raises.
2. Users table non-empty — no-op.
3. ``CYCLONE_ADMIN_USERNAME`` + ``CYCLONE_ADMIN_PASSWORD`` env vars set
(password >= 12 chars) — create the admin and print confirmation.
4. Otherwise — raise ``RuntimeError`` with a remediation hint that
points operators at ``python -m cyclone users create``.
"""
from __future__ import annotations
import os
from sqlalchemy import select
from cyclone.auth import users
from cyclone.auth.deps import AUTH_DISABLED
from cyclone.auth.permissions import Role
from cyclone.db import SessionLocal, User
def run() -> None:
"""Bootstrap the first admin user, or no-op.
See module docstring for behavior. Idempotent: safe to call on
every startup — it short-circuits as soon as the users table is
non-empty.
"""
if os.environ.get("CYCLONE_AUTH_DISABLED") == "1":
# Dev escape hatch — skip bootstrap entirely and tell the API
# to also short-circuit auth checks.
import cyclone.auth.deps as _deps
_deps.AUTH_DISABLED = True
return
username = os.environ.get("CYCLONE_ADMIN_USERNAME")
password = os.environ.get("CYCLONE_ADMIN_PASSWORD")
with SessionLocal()() as db:
existing = db.execute(select(User)).scalars().first()
if existing is not None:
return # users exist — nothing to bootstrap
if not username or not password:
raise RuntimeError(
"Cyclone has no users yet. Set CYCLONE_ADMIN_USERNAME and "
"CYCLONE_ADMIN_PASSWORD env vars (min 12 chars), or run "
"`python -m cyclone users create <username> --role admin`."
)
if len(password) < 12:
raise RuntimeError(
"CYCLONE_ADMIN_PASSWORD must be at least 12 characters."
)
users.create(
db,
username=username,
password=password,
role=Role.ADMIN.value,
)
print(f"[cyclone] bootstrap admin user '{username}' created")
+183
View File
@@ -0,0 +1,183 @@
"""CLI subcommand: ``python -m cyclone users ...``.
Click-based to match the existing parse-837 / parse-835 convention in
``cyclone.cli``. Provides operator-side user management without going
through the admin HTTP API.
Subcommands
-----------
- ``users create USERNAME --role {admin,user,viewer} [--password PW]``
Create a user. If ``--password`` is omitted, prompts (with
confirmation) on the controlling terminal.
- ``users list`` — tab-separated ``id / username / role / state``.
- ``users disable USERNAME`` — set ``disabled_at`` to now.
- ``users reset-password USERNAME [--password PW]`` — replace the hash.
- ``users set-role USERNAME --role {admin,user,viewer}`` — change role.
Exit codes
----------
* 0 — success
* 1 — validation error (unknown username, duplicate username)
* 2 — usage error (missing arg, bad role, short password, unknown
subcommand). Click itself uses 2 for usage errors so the conventional
shell tools (``set -e``, etc.) recognize them.
Passwords shorter than 12 chars are rejected everywhere they appear.
"""
from __future__ import annotations
import getpass
import sys
import click
from cyclone.auth import users
from cyclone.auth.permissions import Role
from cyclone.db import SessionLocal
ROLE_CHOICES = [Role.ADMIN.value, Role.USER.value, Role.VIEWER.value]
MIN_PASSWORD_LEN = 12
def _prompt_password(label: str) -> str:
pw = getpass.getpass(f"{label}: ")
if not pw:
click.echo("Password required.", err=True)
sys.exit(2)
return pw
def _validate_password(pw: str) -> None:
if len(pw) < MIN_PASSWORD_LEN:
click.echo(
f"Password must be at least {MIN_PASSWORD_LEN} characters.",
err=True,
)
sys.exit(2)
# --------------------------------------------------------------------------- #
# Group
# --------------------------------------------------------------------------- #
@click.group(name="users")
def users_cli() -> None:
"""Manage Cyclone users from the command line."""
# --------------------------------------------------------------------------- #
# create
# --------------------------------------------------------------------------- #
@users_cli.command("create")
@click.argument("username")
@click.option(
"--role",
required=True,
type=click.Choice(ROLE_CHOICES, case_sensitive=False),
help="Role to grant the new user.",
)
@click.option(
"--password",
default=None,
help=f"Password (min {MIN_PASSWORD_LEN} chars). Prompts if omitted.",
)
def create_user(username: str, role: str, password: str | None) -> None:
"""Create a new user with the given USERNAME and ROLE."""
pw = password or _prompt_password("Password")
_validate_password(pw)
with SessionLocal()() as db:
if users.get_by_username(db, username) is not None:
click.echo(f"User '{username}' already exists.", err=True)
sys.exit(1)
u = users.create(db, username=username, password=pw, role=role)
click.echo(f"Created user '{u.username}' with role '{u.role}'.")
# --------------------------------------------------------------------------- #
# list
# --------------------------------------------------------------------------- #
@users_cli.command("list")
def list_users() -> None:
"""List all users as id / username / role / state."""
from cyclone.db import User
with SessionLocal()() as db:
for u in db.query(User).order_by(User.id.asc()).all():
state = "disabled" if u.disabled_at else "active"
click.echo(f"{u.id}\t{u.username}\t{u.role}\t{state}")
# --------------------------------------------------------------------------- #
# disable
# --------------------------------------------------------------------------- #
@users_cli.command("disable")
@click.argument("username")
def disable_user(username: str) -> None:
"""Disable USERNAME (sets disabled_at to now)."""
with SessionLocal()() as db:
u = users.get_by_username(db, username)
if u is None:
click.echo(f"No such user: {username}", err=True)
sys.exit(1)
users.disable(db, u.id)
click.echo(f"Disabled '{username}'.")
# --------------------------------------------------------------------------- #
# reset-password
# --------------------------------------------------------------------------- #
@users_cli.command("reset-password")
@click.argument("username")
@click.option(
"--password",
default=None,
help=f"New password (min {MIN_PASSWORD_LEN} chars). Prompts if omitted.",
)
def reset_password(username: str, password: str | None) -> None:
"""Replace USERNAME's password."""
pw = password or _prompt_password("New password")
_validate_password(pw)
with SessionLocal()() as db:
u = users.get_by_username(db, username)
if u is None:
click.echo(f"No such user: {username}", err=True)
sys.exit(1)
users.update_password(db, u.id, pw)
click.echo(f"Password reset for '{username}'.")
# --------------------------------------------------------------------------- #
# set-role
# --------------------------------------------------------------------------- #
@users_cli.command("set-role")
@click.argument("username")
@click.option(
"--role",
required=True,
type=click.Choice(ROLE_CHOICES, case_sensitive=False),
help="Role to grant.",
)
def set_role(username: str, role: str) -> None:
"""Change USERNAME's role."""
with SessionLocal()() as db:
u = users.get_by_username(db, username)
if u is None:
click.echo(f"No such user: {username}", err=True)
sys.exit(1)
users.update_role(db, u.id, role)
click.echo(f"Role for '{username}' set to '{role}'.")
+104
View File
@@ -0,0 +1,104 @@
"""FastAPI dependencies for auth."""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session as DbSession
from cyclone.auth import sessions, users
from cyclone.auth.permissions import Role, allowed_roles
from cyclone.db import SessionLocal
def _db():
db = SessionLocal()()
try:
yield db
finally:
db.close()
DbSessionDep = Annotated[DbSession, Depends(_db)]
AUTH_DISABLED = False
async def get_current_user(
request: Request,
db: DbSessionDep,
) -> dict:
"""Return the public User shape. Raises 401 if session is missing/expired.
When AUTH_DISABLED is True (dev escape hatch), returns a synthetic admin
user without checking credentials.
"""
if AUTH_DISABLED:
return {
"id": 0,
"username": "dev",
"role": Role.ADMIN.value,
"createdAt": None,
"disabledAt": None,
}
sid = request.cookies.get("cyclone_session")
if not sid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="session_expired",
)
sess = sessions.get_valid(db, sid)
if sess is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="session_expired",
)
user = users.get(db, sess.user_id)
if user is None or user.disabled_at is not None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="account_disabled",
)
# Sliding expiry: refresh both DB and cookie.
sessions.touch(db, sid)
request.state.user = user
request.state.session_id = sid
return users.to_public(user)
def require_role(*allowed: Role):
"""Dependency factory: gate the endpoint to specific roles.
Falls back to PERMISSIONS matrix lookup if no explicit roles given.
"""
async def _dep(
request: Request,
user: dict = Depends(get_current_user),
) -> dict:
user_role = user.get("role")
if allowed:
if user_role not in {r.value for r in allowed}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return user
# Otherwise consult the matrix.
method = request.method
path = request.url.path
roles = allowed_roles(method, path)
if roles is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
if user_role not in {r.value for r in roles}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return user
return _dep
+73
View File
@@ -0,0 +1,73 @@
"""Role enum + PERMISSIONS matrix."""
from __future__ import annotations
from enum import Enum
class Role(str, Enum):
ADMIN = "admin"
USER = "user"
VIEWER = "viewer"
ALL_ROLES = {Role.ADMIN, Role.USER, Role.VIEWER}
WRITE_ROLES = {Role.ADMIN, Role.USER}
ADMIN_ONLY = {Role.ADMIN}
# (method, path-prefix) → allowed roles.
# Endpoints not in this matrix default to DENY (fail-closed).
PERMISSIONS: dict[tuple[str, str], set[Role]] = {
# Public paths.
("GET", "/api/healthz"): set(),
("POST", "/api/auth/login"): set(),
# Auth surface.
("POST", "/api/auth/logout"): ALL_ROLES,
("GET", "/api/auth/me"): ALL_ROLES,
# Admin-only user management.
("GET", "/api/admin/users"): ADMIN_ONLY,
("POST", "/api/admin/users"): ADMIN_ONLY,
("PATCH", "/api/admin/users"): ADMIN_ONLY,
("DELETE", "/api/admin/users"): ADMIN_ONLY,
# Read endpoints (all authenticated roles).
("GET", "/api/claims"): ALL_ROLES,
("GET", "/api/remittances"): ALL_ROLES,
("GET", "/api/providers"): ALL_ROLES,
("GET", "/api/batches"): ALL_ROLES,
("GET", "/api/dashboard/summary"): ALL_ROLES,
("GET", "/api/activity"): ALL_ROLES,
("GET", "/api/inbox/lanes"): ALL_ROLES,
("GET", "/api/reconcile"): ALL_ROLES,
("GET", "/api/audit-log"): ADMIN_ONLY,
# Write endpoints (admin + user, no viewer).
("POST", "/api/parse-837"): WRITE_ROLES,
("POST", "/api/parse-835"): WRITE_ROLES,
("POST", "/api/inbox"): WRITE_ROLES,
("POST", "/api/reconcile"): WRITE_ROLES,
("POST", "/api/resubmit"): WRITE_ROLES,
("POST", "/api/acks"): WRITE_ROLES,
# CSV export — read-only.
("GET", "/api/export.csv"): ALL_ROLES,
}
def allowed_roles(method: str, path: str) -> set[Role] | None:
"""Return the set of roles allowed to call (method, path), or None if denied.
Uses longest-prefix match on path; falls back to DENY (None) if no entry matches.
"""
candidates = [
(len(prefix), roles)
for (m, prefix), roles in PERMISSIONS.items()
if m == method and (path == prefix or path.startswith(prefix.rstrip("/") + "/"))
]
if not candidates:
return None
candidates.sort(key=lambda x: -x[0])
return candidates[0][1]
+34
View File
@@ -0,0 +1,34 @@
"""Per-username login rate limiter (in-memory, per-process)."""
from __future__ import annotations
import time
from threading import Lock
WINDOW_SECONDS = 300
MAX_FAILS = 5
_FAILS: dict[str, list[float]] = {}
_LOCK = Lock()
def check(username: str) -> int:
"""Return retry-after seconds, or 0 if allowed."""
now = time.monotonic()
with _LOCK:
fails = [t for t in _FAILS.get(username, []) if now - t < WINDOW_SECONDS]
_FAILS[username] = fails
if len(fails) >= MAX_FAILS:
return int(WINDOW_SECONDS - (now - fails[0]))
return 0
def record_failure(username: str) -> None:
now = time.monotonic()
with _LOCK:
_FAILS.setdefault(username, []).append(now)
def reset(username: str) -> None:
with _LOCK:
_FAILS.pop(username, None)
+97
View File
@@ -0,0 +1,97 @@
"""/api/auth/login, /api/auth/logout, /api/auth/me."""
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from cyclone.auth import rate_limit, sessions, users
from cyclone.auth.deps import get_current_user
from cyclone.db import SessionLocal
router = APIRouter(prefix="/api/auth", tags=["auth"])
COOKIE_NAME = "cyclone_session"
COOKIE_MAX_AGE = 86400 # 24h
def _is_https(request: Request) -> bool:
if request.url.scheme == "https":
return True
return os.environ.get("CYCLONE_BEHIND_HTTPS") == "1"
def _set_cookie(response: Response, sid: str, request: Request) -> None:
response.set_cookie(
key=COOKIE_NAME,
value=sid,
max_age=COOKIE_MAX_AGE,
path="/api",
httponly=True,
samesite="lax",
secure=_is_https(request),
)
def _clear_cookie(response: Response) -> None:
response.delete_cookie(key=COOKIE_NAME, path="/api")
@router.post("/login")
def login(body: dict, request: Request, response: Response):
username = (body.get("username") or "").strip()
password = body.get("password") or ""
if not username or not password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="username and password are required",
)
retry_after = rate_limit.check(username)
if retry_after > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="rate_limited",
headers={"Retry-After": str(retry_after)},
)
with SessionLocal()() as db:
user = users.get_by_username(db, username)
if user is None or not users.verify_password(password, user.password_hash):
rate_limit.record_failure(username)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid_credentials",
)
if user.disabled_at is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="account_disabled",
)
sid, _ = sessions.create(db, user_id=user.id)
rate_limit.reset(username)
public = users.to_public(user)
_set_cookie(response, sid, request)
return public
@router.post("/logout")
def logout(
request: Request,
response: Response,
_user: dict = Depends(get_current_user),
):
sid = request.cookies.get(COOKIE_NAME)
if sid:
with SessionLocal()() as db:
sessions.delete(db, sid)
_clear_cookie(response)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/me")
def me(user: dict = Depends(get_current_user)):
return user
+60
View File
@@ -0,0 +1,60 @@
"""Session create/validate/expire/touch."""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from cyclone.db import Session
SESSION_LIFETIME = timedelta(hours=24)
def create(db, *, user_id: int) -> tuple[str, Session]:
sid = secrets.token_urlsafe(32)
now = datetime.now(timezone.utc)
expires_at = now + SESSION_LIFETIME
sess = Session(
id=sid,
user_id=user_id,
expires_at=expires_at,
created_at=now,
)
db.add(sess)
db.commit()
db.refresh(sess)
# SQLite strips tzinfo on roundtrip; restore it so callers don't have to.
sess.expires_at = expires_at
return sid, sess
def get_valid(db, sid: str) -> Session | None:
sess = db.execute(
select(Session).where(Session.id == sid)
).scalar_one_or_none()
if sess is None:
return None
# SQLite drops tzinfo on roundtrip; normalize to UTC before comparing.
if sess.expires_at.tzinfo is None:
sess.expires_at = sess.expires_at.replace(tzinfo=timezone.utc)
if sess.expires_at <= datetime.now(timezone.utc):
return None
return sess
def delete(db, sid: str) -> None:
sess = db.get(Session, sid)
if sess is None:
return
db.delete(sess)
db.commit()
def touch(db, sid: str) -> None:
sess = db.get(Session, sid)
if sess is None:
return
sess.expires_at = datetime.now(timezone.utc) + SESSION_LIFETIME
db.commit()
+79
View File
@@ -0,0 +1,79 @@
"""User CRUD + bcrypt password hashing."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from passlib.hash import bcrypt
from sqlalchemy import select
from cyclone.db import User
def hash_password(plaintext: str) -> str:
return bcrypt.hash(plaintext)
def verify_password(plaintext: str, hashed: str) -> bool:
try:
return bcrypt.verify(plaintext, hashed)
except (ValueError, TypeError):
return False
def create(db, *, username: str, password: str, role: str) -> User:
user = User(
username=username,
password_hash=hash_password(password),
role=role,
created_at=datetime.now(timezone.utc),
)
db.add(user)
db.commit()
db.refresh(user)
return user
def get_by_username(db, username: str) -> User | None:
return db.execute(
select(User).where(User.username == username)
).scalar_one_or_none()
def get(db, user_id: int) -> User | None:
return db.get(User, user_id)
def disable(db, user_id: int) -> None:
user = db.get(User, user_id)
if user is None:
return
user.disabled_at = datetime.now(timezone.utc)
db.commit()
def update_role(db, user_id: int, role: str) -> None:
user = db.get(User, user_id)
if user is None:
return
user.role = role
db.commit()
def update_password(db, user_id: int, new_password: str) -> None:
user = db.get(User, user_id)
if user is None:
return
user.password_hash = hash_password(new_password)
db.commit()
def to_public(user: User) -> dict[str, Any]:
return {
"id": user.id,
"username": user.username,
"role": user.role,
"createdAt": user.created_at.isoformat() if user.created_at else None,
"disabledAt": user.disabled_at.isoformat() if user.disabled_at else None,
}
+6
View File
@@ -74,6 +74,12 @@ def main(ctx: click.Context, log_format: str | None, log_file: Path | None) -> N
ctx.ensure_object(dict)
# Register the auth users subgroup. Imported here (not at module top) to
# avoid pulling passlib / bcrypt at CLI parse-only import time.
from cyclone.auth.cli import users_cli # noqa: E402
main.add_command(users_cli)
@main.command("parse-837")
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path))
+30
View File
@@ -30,6 +30,7 @@ from sqlalchemy import (
Numeric,
String,
Text,
func,
text,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
@@ -668,6 +669,11 @@ class AuditLog(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
prev_hash: Mapped[str] = mapped_column(String(64), nullable=False)
hash: Mapped[str] = mapped_column(String(64), nullable=False)
# SP-auth: which authenticated user performed this action. Nullable
# so existing (pre-auth) rows and system-initiated events stay valid.
# NOT part of the hash chain — verify_chain must continue to work on
# legacy rows that pre-date this column.
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
__table_args__ = (
Index("idx_audit_log_entity", "entity_type", "entity_id"),
@@ -836,3 +842,27 @@ class ClearhouseORM(Base):
filename_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
sftp_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
updated_at: Mapped[str] = mapped_column(String(32), nullable=False)
class User(Base):
"""Auth user (admin / user / viewer)."""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(16), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Session(Base):
"""Server-side auth session (HttpOnly cookie holds the id)."""
__tablename__ = "sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+11 -6
View File
@@ -4,8 +4,8 @@ SP9. Source-of-truth spec:
https://hcpf.colorado.gov/tp-x12-filenaming (HCPF X12 File Naming Standards Quick Guide)
Outbound (we send):
{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
Example: 11525703-837P-20260620132243505-1of1.x12
tp{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
Example: tp11525703-837P-20260620132243505-1of1.x12
Inbound (HPE sends to our ToHPE):
TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
@@ -28,14 +28,15 @@ from cyclone.providers import InboundFilename
# Regexes
# ---------------------------------------------------------------------------
# Outbound: 11525703-837P-20260620132243505-1of1.x12
# Outbound: tp11525703-837P-20260620132243505-1of1.x12
# - tp: literal "tp" prefix
# - tpid: 1+ digits
# - tx: 1+ alnum
# - ts: 17 digits (yyyymmddhhmmssSSS)
# - seq: literal "1of1"
# - ext: 1+ alnum
OUTBOUND_RE = re.compile(
r"^(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
r"^tp(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
)
# Inbound: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
@@ -79,7 +80,8 @@ def build_outbound_filename(
time in ``America/Denver`` is used.
Returns:
Filename like "11525703-837P-20260620132243505-1of1.x12"
Filename like "tp11525703-837P-20260620132243505-1of1.x12"
(note the ``tp`` prefix per HCPF outbound spec).
Raises:
ValueError: If tpid is non-numeric, tx contains invalid chars, or
@@ -99,7 +101,10 @@ def build_outbound_filename(
# Format: yyyymmddhhmmssSSS — 17 digits total
ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}"
assert len(ts) == 17
return f"{tpid}-{tx}-{ts}-1of1.{ext}"
# Per HCPF outbound spec, prefix is "tp" + tpid. Matches the format
# we receive from HPE inbound (which uses uppercase TP) and the
# historical outbound prodfile naming (e.g. tp11525703-837P-...).
return f"tp{tpid}-{tx}-{ts}-1of1.{ext}"
# ---------------------------------------------------------------------------
@@ -0,0 +1,31 @@
-- version: 10
-- Auth (SP-auth): users + sessions tables.
--
-- `users` holds the local credential store: bcrypt-hashed password,
-- role enum ('admin' | 'user' | 'viewer'), and a soft-delete column
-- (disabled_at) so admins can revoke access without losing history.
--
-- `sessions` holds the server-side session rows; the browser only
-- carries an opaque token cookie (cyclone_session) that points here.
-- expires_at index lets us cheaply reap stale sessions.
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
disabled_at TEXT
);
CREATE INDEX idx_users_username ON users(username);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
@@ -0,0 +1,10 @@
-- version: 11
-- Auth (SP-auth): record the acting user_id on every audit_log entry.
--
-- Backwards-compatible: existing rows get NULL user_id (they were
-- written by the pre-auth `system` actor). Going forward, the FastAPI
-- get_current_user dependency injects the id into every audit log call.
ALTER TABLE audit_log ADD COLUMN user_id INTEGER;
CREATE INDEX idx_audit_log_user_id ON audit_log(user_id);
+9
View File
@@ -63,6 +63,7 @@ class ClaimHeader(_Base):
frequency_code: str | None = None
provider_signature: str | None = None
assignment: str | None = None
benefits_assignment_certification: str | None = None # CLM08 (Y/N)
release_of_info: str | None = None
prior_auth: str | None = None
@@ -87,6 +88,14 @@ class ServiceLine(_Base):
place_of_service: str | None = None
service_date: date | None = None
provider_reference: str | None = None
# SV1-07 — Diagnosis Code Pointer. Points to one or more
# diagnosis codes in the parent claim's HI segment ("1".. "12",
# space-separated when multiple). For 837P with a non-empty HI
# segment, SV1-07 is required by HCPF / Gainwell. The parser
# captures it from the source; the serializer defaults to "1"
# when the claim has at least one diagnosis and no explicit
# pointer was captured (matches the common single-dx case).
dx_pointer: str | None = None
class ValidationIssue(_Base):
+8
View File
@@ -202,6 +202,7 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
frequency_code=freq or None,
provider_signature=clm[6] if len(clm) > 6 else None,
assignment=clm[7] if len(clm) > 7 else None,
benefits_assignment_certification=clm[8] if len(clm) > 8 else None,
release_of_info=clm[9] if len(clm) > 9 else None,
)
@@ -285,6 +286,12 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
except Exception:
units = None
place_of_service = seg[5] if len(seg) > 5 else None
# SV1-06 (Unit Basis of Measurement) is X12 "UN" for "units" — we
# already use unit_type in SV1-03; SV1-06 is rarely populated and
# is not required by HCPF.
# SV1-07 — Diagnosis Code Pointer (e.g. "1" for the first HI
# diagnosis). Required by HCPF when the claim has diagnoses.
dx_pointer = seg[7] if len(seg) > 7 and seg[7] else None
service_date: date | None = None
provider_ref: str | None = None
@@ -311,6 +318,7 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
place_of_service=place_of_service,
service_date=service_date,
provider_reference=provider_ref,
dx_pointer=dx_pointer,
),
idx,
)
+149 -49
View File
@@ -112,7 +112,10 @@ def _build_gs(sender_id: str, receiver_id: str, group_control_number: str) -> st
_FUNCTIONAL_ID_HEALTH_CARE,
sender_id,
receiver_id,
_today_yymmdd(),
# GS-04 must be CCYYMMDD (8 digits) per X12 — ISA uses YYMMDD
# (6 digits) for the older format, but the GS segment is the
# newer ANSI X12 format and requires the full year.
_today_yyyymmdd(),
_today_hhmm(),
group_control_number,
"X",
@@ -186,17 +189,30 @@ def _build_nm1(entity_id_qualifier: str, entity_type: str, name: str,
return _ELEM.join(parts) + _SEG
def _build_per(contact_name: str | None, contact_phone: str | None) -> str:
"""PER segment — submitter contact. Returns empty when no contact info."""
if not contact_name and not contact_phone:
return ""
parts = [
"PER",
"IC", # PER01 — contact function code (Information Contact)
contact_name or "",
"TE", # PER03 — phone qualifier
contact_phone or "",
]
def _build_per(
contact_name: str | None,
contact_phone: str | None,
contact_email: str | None = None,
email_qual: str = "EM",
) -> str:
"""PER segment — submitter contact (Loop 1000A).
X12 005010X222A1 *requires* at least one PER segment in Loop 1000A
(Submitter Name) and at least PER01 must be present, so this
builder always emits a segment. PER01 = "IC" (Information Contact).
The remaining elements are filled from the available contact info:
name, then email (preferred — Gainwell/HCPF expect this), then phone.
"""
parts = ["PER", "IC"]
if contact_name:
parts.append(contact_name)
if contact_email:
parts.append(email_qual) # PER03 — email qualifier (default "EM")
parts.append(contact_email) # PER04 — the email itself
elif contact_phone:
parts.append("TE") # PER03 — phone qualifier
parts.append(contact_phone) # PER04 — the phone itself
return _ELEM.join(parts) + _SEG
@@ -236,26 +252,40 @@ def _build_hl(hl_id: str, parent_id: str, level_code: str, child_code: str) -> s
return _ELEM.join(parts) + _SEG
def _build_sbr(relationship_code: str | None, member_id: str | None,
payer_name: str | None) -> str:
def _build_sbr(
individual_relationship_code: str | None,
claim_filing_indicator_code: str | None,
) -> str:
"""SBR segment — subscriber information.
SBR01 (relationship code) defaults to ``"P"`` (Patient = self) which is
the most common case for professional claims; the parser does not store
this on the canonical Subscriber model so we cannot thread it through
without adding a model field.
Slot layout (X12 005010X222A1):
SBR01 — Payer Responsibility Sequence Number Code. Default ``"P"``
(Patient = primary). The parser does not capture this
field on ``ClaimOutput`` so we default it.
SBR02 — Individual Relationship Code. ``"18"`` = self, ``"01"`` = spouse, etc.
The parser does not capture this either; we default ``"18"``
for the common self-pay case.
SBR09 — Claim Filing Indicator Code. ``"MC"`` for Medicaid,
``"16"`` for Medicare Part B, etc. The canonical
PayerConfig837 carries ``sbr09_default``; we thread it in
from the caller.
The member_id and payer name do NOT belong in SBR — the member_id
lives in NM109 of the NM1*IL segment, and the payer name is in
NM103 of NM1*PR. (Earlier revisions of this function put them in
SBR06 / SBR09, which is wrong and rejected by HCPF.)
"""
parts = [
"SBR",
relationship_code or "P",
"", # SBR02 — group number
"", # SBR03 — group name
"", # SBR04 — claim filing indicator code
"", # SBR05 — sequence number code
payer_name or "", # SBR06 — claim filing indicator code (CO uses MC)
"", # SBR07
"", # SBR08
member_id or "", # SBR09 — claim submitter's id
"P", # SBR01 — primary
individual_relationship_code or "18", # SBR02 — self
"", # SBR03 — group number
"", # SBR04 — group name
"", # SBR05 — insurance type code
"", # SBR06 — coordination of benefits
"", # SBR07 — yes/no condition
"", # SBR08 — employment status code
claim_filing_indicator_code or "", # SBR09 — claim filing indicator
]
return _ELEM.join(parts) + _SEG
@@ -300,7 +330,11 @@ def _build_clm(claim) -> str:
clm05, # CLM05 — composite POS:qualifier:frequency_code
claim.provider_signature or "Y", # CLM06
claim.assignment or "Y", # CLM07
"", # CLM08 — benefit assignment certification
# CLM08 — Benefits Assignment Certification. X12 837P requires
# this when CLM07 = "Y" (the common case for in-network
# professional claims). Default to "Y" when the source did
# not capture one — matches what 99% of HCPF files look like.
claim.benefits_assignment_certification or "Y", # CLM08
claim.release_of_info or "Y", # CLM09
]
return _ELEM.join(parts) + _SEG
@@ -325,21 +359,41 @@ def _build_lx(line_number: int) -> str:
return _ELEM.join(["LX", str(line_number)]) + _SEG
def _build_sv1(line) -> str:
"""SV1 segment — professional service line."""
def _build_sv1(line, *, dx_pointer: str | None = None) -> str:
"""SV1 segment — professional service line.
X12 005010X222A1 layout (837P):
SV1-01 composite procedure identifier
SV1-02 monetary amount (charge)
SV1-03 unit of basis measurement (UN, MJ, etc.) — ``line.unit_type``
SV1-04 service unit count — ``line.units``
SV1-05 place of service code — ``line.place_of_service``
SV1-06 **NOT USED** by this guide (must be empty)
SV1-07 diagnosis code pointer — ``dx_pointer`` (required when the
parent claim has an HI segment)
The parser captures the original SV1-07 pointer when present; the
serializer defaults it to ``"1"`` (pointing at the first HI
diagnosis) when the claim has diagnoses and no explicit pointer
was captured. When the claim has no HI segment we leave SV1-07
empty to match the spec.
"""
proc = line.procedure
code = proc.code if proc else ""
mods = proc.modifiers if proc else []
composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4])
charge = f"{Decimal(line.charge or 0):.2f}"
units = f"{Decimal(line.units):g}" if line.units is not None else "1"
sv1_07 = dx_pointer or ""
parts = [
"SV1",
composite,
charge,
line.unit_type or "UN",
units,
line.place_of_service or "",
composite, # SV1-01
charge, # SV1-02
line.unit_type or "UN", # SV1-03 — unit basis code
units, # SV1-04
line.place_of_service or "", # SV1-05
"", # SV1-06 — NOT USED in 837P
sv1_07, # SV1-07 — diagnosis pointer
]
return _ELEM.join(parts) + _SEG
@@ -357,15 +411,20 @@ def _build_dtp_472(service_date: date | None) -> str:
# ---------------------------------------------------------------------------
def _build_submitter_block(sender_id: str, submitter_name: str | None,
contact_name: str | None,
contact_phone: str | None) -> list[str]:
def _build_submitter_block(
sender_id: str,
submitter_name: str | None,
contact_name: str | None,
contact_phone: str | None,
contact_email: str | None = None,
email_qual: str = "EM",
) -> list[str]:
out = [
_build_nm1("41", "41", submitter_name or sender_id, "46", sender_id),
]
per = _build_per(contact_name, contact_phone)
if per:
out.append(per)
# PER is required by X12 (at least PER01). _build_per always emits
# the segment; the submitter block always has exactly one.
out.append(_build_per(contact_name, contact_phone, contact_email, email_qual))
return out
@@ -395,11 +454,14 @@ def _build_billing_provider_block(provider) -> list[str]:
return out
def _build_subscriber_block(subscriber, payer_name: str | None) -> list[str]:
def _build_subscriber_block(
subscriber,
claim_filing_indicator_code: str | None,
) -> list[str]:
"""HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
out = [
_build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children
_build_sbr("18", subscriber.member_id, payer_name),
_build_sbr("18", claim_filing_indicator_code),
_build_nm1(
"IL", "IL",
f"{subscriber.last_name} {subscriber.first_name}".strip(),
@@ -427,12 +489,24 @@ def _build_payer_block(payer) -> list[str]:
]
def _build_service_lines_block(service_lines) -> list[str]:
"""Per line: LX / SV1 / DTP*472 / REF*6R."""
def _build_service_lines_block(service_lines, *, has_diagnoses: bool = False) -> list[str]:
"""Per line: LX / SV1 / DTP*472 / REF*6R.
``has_diagnoses`` is True when the parent claim emits an HI segment;
in that case SV1-07 is required by X12 and we default each line's
pointer to ``"1"`` (the first HI diagnosis) unless the source
captured a different pointer on the line itself.
"""
out: list[str] = []
for idx, line in enumerate(service_lines or [], start=1):
out.append(_build_lx(idx))
out.append(_build_sv1(line))
# Prefer the line's captured pointer (parser pulled SV1-07
# when present). Fall back to "1" only when the claim has
# diagnoses and the source had no explicit pointer — the
# common single-diagnosis case.
line_pointer = getattr(line, "dx_pointer", None)
effective_pointer = line_pointer or ("1" if has_diagnoses else "")
out.append(_build_sv1(line, dx_pointer=effective_pointer))
dtp = _build_dtp_472(line.service_date)
if dtp:
out.append(dtp)
@@ -450,7 +524,10 @@ def serialize_837(
submitter_name: str | None = None,
submitter_contact_name: str | None = None,
submitter_contact_phone: str | None = None,
submitter_contact_email: str | None = None,
submitter_contact_email_qual: str = "EM",
receiver_name: str | None = None,
claim_filing_indicator_code: str | None = None,
interchange_control_number: str = "000000001",
group_control_number: str = "1",
) -> str:
@@ -462,6 +539,16 @@ def serialize_837(
(``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass
the configured values.
The submitter block (Loop 1000A) always emits a PER segment per the
X12 spec — the canonical clearhouse config provides the contact
name and email so callers should pass them through.
The claim filing indicator (SBR09) is read from the per-payer
config (``PayerConfig837.sbr09_default``); callers should pass it
in. If not passed, SBR09 is left empty (which causes the
:func:`cyclone.parsers.validator._r202_sbr09_allowed` rule to skip
its check — degraded but not a hard error).
Editable fields (CLM, REF*G1, HI, service-line SV1, DTP*472) are
emitted from the canonical ``ClaimOutput`` fields, so post-parse
edits propagate to the output.
@@ -481,11 +568,13 @@ def serialize_837(
),
]
segments.extend(_build_submitter_block(
sender_id, submitter_name, submitter_contact_name, submitter_contact_phone,
sender_id, submitter_name,
submitter_contact_name, submitter_contact_phone, submitter_contact_email,
submitter_contact_email_qual,
))
segments.extend(_build_receiver_block(receiver_id, receiver_name))
segments.extend(_build_billing_provider_block(claim.billing_provider))
segments.extend(_build_subscriber_block(claim.subscriber, claim.payer.name))
segments.extend(_build_subscriber_block(claim.subscriber, claim_filing_indicator_code))
segments.extend(_build_payer_block(claim.payer))
# Claim-level editable segments.
@@ -497,7 +586,10 @@ def serialize_837(
segments.append(_build_hi(claim.diagnoses))
# Service lines (LX / SV1 / DTP*472 / REF*6R).
segments.extend(_build_service_lines_block(claim.service_lines))
segments.extend(_build_service_lines_block(
claim.service_lines,
has_diagnoses=bool(claim.diagnoses),
))
# SE segment count includes ST (line 3, 1-based) through SE itself
# — i.e. the entire ST..SE block inclusive.
@@ -514,15 +606,23 @@ def serialize_837_for_resubmit(
claim: ClaimOutput,
*,
interchange_index: int,
**kwargs,
) -> str:
"""Like :func:`serialize_837` but assigns deterministic-but-unique
interchange + group control numbers for a bundle position.
Interchange number = ``f"{interchange_index:09d}"``.
Group number = ``str(interchange_index)``.
All other keyword arguments (sender_id, receiver_id, submitter_*
and receiver_* contact info, claim_filing_indicator_code) are
forwarded to :func:`serialize_837` unchanged so callers — like the
export and SFTP-submit endpoints — can pass through clearhouse +
payer config without copying the signature.
"""
return serialize_837(
claim,
interchange_control_number=f"{interchange_index:09d}",
group_control_number=str(interchange_index),
**kwargs,
)
+1 -1
View File
@@ -2277,7 +2277,7 @@ class CycloneStore:
submitter_contact_email="tyler@dzinesco.com",
filename_block={
"tz": "America/Denver",
"outbound_template": "{tpid}-{tx}-{ts_mt}-1of1.{ext}",
"outbound_template": "tp{tpid}-{tx}-{ts_mt}-1of1.{ext}",
"inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12",
},
sftp_block={
+8
View File
@@ -101,6 +101,14 @@ def test_parse_837_endpoint_streams_ndjson(client: TestClient):
# Summary numbers match the JSON path.
assert parsed[3]["data"]["total_claims"] == 2
assert parsed[3]["data"]["passed"] == 2
# The streaming summary carries the server-side batch id so the
# frontend can call /api/batches/{id}/export-837 without a separate
# GET /api/batches round-trip (regression: it used to be missing
# on the stream path, only present on the JSON path, which made the
# Upload page's Export button say "no batch to export").
assert parsed[3]["type"] == "summary"
assert isinstance(parsed[3]["data"]["batch_id"], str)
assert len(parsed[3]["data"]["batch_id"]) == 32 # uuid4().hex
def test_parse_837_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
+7
View File
@@ -82,6 +82,13 @@ def test_parse_835_endpoint_streams_ndjson(client: TestClient):
# Summary numbers match the JSON path.
assert parsed[7]["data"]["total_claims"] == 2
assert parsed[7]["data"]["passed"] == 2
# The streaming summary carries the server-side batch id so the
# frontend can call /api/batches/{id}/export-837 without a separate
# GET /api/batches round-trip (matches the parallel fix on
# /api/parse-837).
assert parsed[7]["type"] == "summary"
assert isinstance(parsed[7]["data"]["batch_id"], str)
assert len(parsed[7]["data"]["batch_id"]) == 32 # uuid4().hex
def test_parse_835_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
+317
View File
@@ -0,0 +1,317 @@
"""Tests for POST /api/batches/{batch_id}/export-837.
Reads Claim.raw_json for each requested claim_id and returns a ZIP of
regenerated X12 837 files. No DB state mutation. Mirrors the
X-Cyclone-Serialize-Errors convention from /api/inbox/rejected/resubmit?download=true.
"""
import io
import json
import zipfile
from pathlib import Path
from fastapi.testclient import TestClient
from cyclone.api import app
def _seed_batch(client: TestClient, filename: str = "claim.txt") -> dict:
"""Parse a single 837P file and return a dict with ``batch_id`` and
``claims``.
The parse-837 happy-path response does not currently surface
``batch_id`` at the top level (it's a server-side UUID, surfaced in
409 errors but not in 200s). We look it up from the DB — the Batch
row is already persisted by the time the parse endpoint returns.
"""
from cyclone import db
from cyclone.db import Batch
fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text()
r = client.post(
"/api/parse-837",
files={"file": (filename, io.BytesIO(fixture.encode()), "text/plain")},
headers={"Accept": "application/json"},
)
assert r.status_code == 200, r.text
body = r.json()
with db.SessionLocal()() as s:
most_recent = s.query(Batch).order_by(Batch.parsed_at.desc()).first()
assert most_recent is not None, "expected at least one Batch row after parse"
batch_id = most_recent.id
return {"batch_id": batch_id, "claims": body["claims"]}
def _claim_ids_from_seed(seeded: dict) -> list[str]:
return [c["claim_id"] for c in seeded["claims"]]
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
def test_happy_path_returns_zip_with_one_x12_per_claim():
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
claim_ids = _claim_ids_from_seed(seeded)
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": claim_ids},
)
assert r.status_code == 200, r.text
assert r.headers["content-type"].startswith("application/zip")
assert "attachment" in r.headers["content-disposition"]
assert (
f"batch-{batch_id}-{len(claim_ids)}-claims.zip"
in r.headers["content-disposition"]
)
# Per-claim failures header absent on full success.
assert "x-cyclone-serialize-errors" not in {k.lower() for k in r.headers.keys()}
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
names = zf.namelist()
assert len(names) == len(claim_ids)
# Each entry follows the HCPF outbound naming template
# "{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12" (the seeded
# clearhouse TPID is "11525703"). Every name must be unique.
from cyclone.edi.filenames import is_outbound_filename
seen = set()
for name in names:
assert is_outbound_filename(name), (
f"expected HCPF outbound filename, got {name!r}"
)
assert name not in seen, f"duplicate filename: {name}"
seen.add(name)
with zf.open(name) as f:
first_line = f.readline().decode("ascii", errors="replace")
assert first_line.startswith("ISA*"), f"{name} didn't start with ISA"
def test_each_x12_in_zip_uses_clearhouse_submit_and_payer_receiver():
"""Regression: regenerated 837s used to emit 'CYCLONE' / 'RECEIVER'
placeholders. The export endpoint must thread the clearhouse
submitter (dzinesco's TPID 11525703) and payer receiver
(COMEDASSISTPROG) through to the serializer."""
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
claim_ids = _claim_ids_from_seed(seeded)
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": claim_ids},
)
assert r.status_code == 200, r.text
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
for name in zf.namelist():
with zf.open(name) as f:
text = f.read().decode("ascii")
# Submitter block must use the clearhouse TPID + name, not
# the 'CYCLONE' placeholder.
assert "CYCLONE" not in text, f"{name} still emits CYCLONE placeholder"
assert "11525703" in text, f"{name} missing clearhouse TPID"
assert "Dzinesco" in text, f"{name} missing clearhouse name"
# Receiver block must use the CO_TXIX payer config
# (COMEDASSISTPROG), not the 'RECEIVER' placeholder.
assert "RECEIVER" not in text, f"{name} still emits RECEIVER placeholder"
assert "COMEDASSISTPROG" in text, f"{name} missing receiver id"
# Loop 1000A requires PER — must be present, not omitted.
assert "PER*IC*" in text, f"{name} missing required PER segment"
# SBR09 should be 'MC' (Medicaid claim filing indicator),
# not the member id.
sbr_line = next(seg for seg in text.split("~") if seg.startswith("SBR*"))
sbr09 = sbr_line.rstrip("~").split("*")[9]
assert sbr09 == "MC", f"{name} SBR09 expected 'MC', got {sbr09!r}"
def test_each_x12_in_zip_round_trips_through_parser():
"""Fidelity check: each .x12 must parse back to a ClaimOutput deep-equal
to the source row's raw_json (modulo recomputed validation)."""
from cyclone.parsers.parse_837 import parse
from cyclone.parsers.payer import PayerConfig
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
claim_ids = _claim_ids_from_seed(seeded)
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": claim_ids},
)
assert r.status_code == 200, r.text
by_id = {c["claim_id"]: c for c in seeded["claims"]}
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
for name in zf.namelist():
with zf.open(name) as f:
text = f.read().decode("ascii")
result = parse(text, PayerConfig(name="CO_MEDICAID"))
assert result.claims, f"{name} didn't parse back to any claims"
# Claim ids must round-trip (proves the serializer didn't drop
# or rewrite the id).
assert result.claims[0].claim.claim_id in by_id, (
f"{name} parsed to an unknown claim_id"
)
# ---------------------------------------------------------------------------
# Partial-failure surface
# ---------------------------------------------------------------------------
def test_partial_failure_one_claim_with_no_raw_json_returns_zip_and_errors_header():
"""If one claim has raw_json=None, the ZIP still returns for the others
and the failure is surfaced via X-Cyclone-Serialize-Errors."""
from cyclone import db
from cyclone.edi.filenames import is_outbound_filename
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
claim_ids = _claim_ids_from_seed(seeded)
assert len(claim_ids) >= 2, "fixture must produce at least 2 claims"
# Wipe raw_json on one claim to simulate a corrupted row.
with db.SessionLocal()() as s:
from cyclone.db import Claim
target = s.get(Claim, claim_ids[0])
target.raw_json = None
s.commit()
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": claim_ids},
)
assert r.status_code == 200, r.text
# Filename uses SUCCESS count, not requested count.
expected_success = len(claim_ids) - 1
assert (
f"batch-{batch_id}-{expected_success}-claims.zip"
in r.headers["content-disposition"]
)
err_header = r.headers.get("x-cyclone-serialize-errors")
assert err_header, "expected X-Cyclone-Serialize-Errors header"
errs = json.loads(err_header)
assert len(errs) == 1
assert errs[0]["claim_id"] == claim_ids[0]
assert "raw_json" in errs[0]["reason"].lower()
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
names = zf.namelist()
assert len(names) == expected_success
# Every entry follows HCPF outbound template; none is the
# 'claim-CLM-X.x12' placeholder from the old endpoint.
for name in names:
assert is_outbound_filename(name), f"non-HCPF name: {name!r}"
def test_all_claims_fail_to_serialize_returns_422():
from cyclone import db
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
claim_ids = _claim_ids_from_seed(seeded)
assert len(claim_ids) >= 1
with db.SessionLocal()() as s:
from cyclone.db import Claim
for cid in claim_ids:
c = s.get(Claim, cid)
c.raw_json = None
s.commit()
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": claim_ids},
)
assert r.status_code == 422, r.text
body = r.json()
# The 422 body surfaces the failure list so the UI can show details
# without parsing a header.
errs = body.get("detail", {}).get("serialize_errors") or body.get("serialize_errors")
assert errs is not None
assert len(errs) == len(claim_ids)
for entry in errs:
assert entry["claim_id"] in claim_ids
# ---------------------------------------------------------------------------
# Error cases
# ---------------------------------------------------------------------------
def test_empty_claim_ids_returns_400():
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": []},
)
assert r.status_code == 400, r.text
def test_missing_claim_ids_key_returns_400():
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={},
)
assert r.status_code == 400, r.text
def test_unknown_batch_id_returns_404():
with TestClient(app) as client:
r = client.post(
"/api/batches/BATCH-DOES-NOT-EXIST/export-837",
json={"claim_ids": ["CLM-1"]},
)
assert r.status_code == 404, r.text
body = r.json()
assert "BATCH-DOES-NOT-EXIST" in (body.get("detail") or "")
def test_unknown_claim_id_silently_omitted():
"""A claim_id that doesn't exist is omitted from the ZIP and surfaced
in the errors header — same convention as the resubmit endpoint."""
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
real_ids = _claim_ids_from_seed(seeded)
assert real_ids, "fixture must produce at least 1 claim"
requested = real_ids + ["CLM-GHOST"]
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": requested},
)
assert r.status_code == 200, r.text
# Filename uses success count = len(real_ids), not len(requested).
assert (
f"batch-{batch_id}-{len(real_ids)}-claims.zip"
in r.headers["content-disposition"]
)
err_header = r.headers.get("x-cyclone-serialize-errors")
assert err_header
errs = json.loads(err_header)
assert any(e["claim_id"] == "CLM-GHOST" for e in errs)
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
names = zf.namelist()
# HCPF outbound filenames, one per real claim. We don't pin the
# exact ts (it depends on the wall clock at test time), but the
# count and the HCPF template must match.
from cyclone.edi.filenames import is_outbound_filename
assert len(names) == len(real_ids)
for name in names:
assert is_outbound_filename(name), f"non-HCPF name: {name!r}"
@@ -0,0 +1,29 @@
"""Tests that /api/parse-837 includes the persisted batch_id in its
JSON response, so the frontend can correlate its in-memory batch with
the server's row (and later call /api/batches/{id}/export-837).
The 409 (duplicate) path has always included batch_id; the 200 path
did not. This brings the 200 path in line so the frontend doesn't have
to query listBatches after every parse just to learn the server's id.
"""
import io
from pathlib import Path
from fastapi.testclient import TestClient
from cyclone.api import app
def test_parse_837_happy_path_includes_batch_id_in_response():
with TestClient(app) as client:
fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text()
r = client.post(
"/api/parse-837",
files={"file": ("claim.txt", io.BytesIO(fixture.encode()), "text/plain")},
headers={"Accept": "application/json"},
)
assert r.status_code == 200, r.text
body = r.json()
assert "batch_id" in body, "parse-837 response must include batch_id so the frontend can call /api/batches/{id}/export-837"
# Sanity: the batch_id should be a non-empty string (UUID-shaped).
assert isinstance(body["batch_id"], str) and body["batch_id"]
+61
View File
@@ -0,0 +1,61 @@
"""Audit log entries record the acting user_id.
Schema: the ``audit_log`` table has a ``user_id INTEGER`` column added
by migration 0011; the SQLAlchemy ``AuditLog`` model itself does not
declare it yet, so ``append_event`` cannot pass it through. These tests
fail before the model + dataclass are updated, and pass after.
"""
from __future__ import annotations
import pytest
from sqlalchemy import delete, select
from cyclone.audit_log import AuditEvent, append_event
from cyclone.db import AuditLog, SessionLocal, User
@pytest.fixture(autouse=True)
def _clear():
with SessionLocal()() as db:
db.execute(delete(AuditLog))
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(AuditLog))
db.execute(delete(User))
db.commit()
def test_append_event_accepts_user_id_kwarg():
with SessionLocal()() as db:
append_event(
db,
AuditEvent(
event_type="test",
entity_type="x",
entity_id="y",
user_id=42,
),
)
db.commit()
with SessionLocal()() as db:
row = db.execute(select(AuditLog)).scalars().one()
assert row.user_id == 42
def test_append_event_without_user_id_is_null():
with SessionLocal()() as db:
append_event(
db,
AuditEvent(
event_type="test",
entity_type="x",
entity_id="y",
),
)
db.commit()
with SessionLocal()() as db:
row = db.execute(select(AuditLog)).scalars().one()
assert row.user_id is None
+103
View File
@@ -0,0 +1,103 @@
"""Admin-only user management endpoints."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete
from cyclone.api import app
from cyclone.auth import users
from cyclone.db import Session as DbSession
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear():
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
@pytest.fixture
def admin_client():
client = TestClient(app)
with SessionLocal()() as db:
users.create(db, username="root", password="rootpassword1", role="admin")
login = client.post(
"/api/auth/login",
json={"username": "root", "password": "rootpassword1"},
)
assert login.status_code == 200
return client
@pytest.fixture
def user_client():
client = TestClient(app)
with SessionLocal()() as db:
users.create(db, username="plain", password="plainpassword1", role="user")
login = client.post(
"/api/auth/login",
json={"username": "plain", "password": "plainpassword1"},
)
return client
def test_list_users_as_admin(admin_client):
with SessionLocal()() as db:
users.create(db, username="alice", password="hunter2hunter2", role="user")
resp = admin_client.get("/api/admin/users")
assert resp.status_code == 200
body = resp.json()
usernames = {u["username"] for u in body}
assert {"root", "alice"} <= usernames
def test_list_users_as_nonadmin_returns_403(user_client):
resp = user_client.get("/api/admin/users")
assert resp.status_code == 403
def test_create_user_as_admin(admin_client):
resp = admin_client.post(
"/api/admin/users",
json={"username": "newbie", "password": "newbiepassword1", "role": "viewer"},
)
assert resp.status_code == 201
assert resp.json()["username"] == "newbie"
assert resp.json()["role"] == "viewer"
def test_create_user_rejects_invalid_role(admin_client):
resp = admin_client.post(
"/api/admin/users",
json={"username": "badrole", "password": "hunter2hunter2", "role": "owner"},
)
assert resp.status_code == 422
def test_patch_user_role_and_password(admin_client):
with SessionLocal()() as db:
u = users.create(db, username="subject", password="hunter2hunter2", role="viewer")
resp = admin_client.patch(
f"/api/admin/users/{u.id}",
json={"role": "user", "password": "newpassword1"},
)
assert resp.status_code == 200
assert resp.json()["role"] == "user"
def test_admin_cannot_demote_self(admin_client):
me = admin_client.get("/api/auth/me").json()
resp = admin_client.patch(
f"/api/admin/users/{me['id']}",
json={"role": "viewer"},
)
assert resp.status_code == 409
+79
View File
@@ -0,0 +1,79 @@
"""Bootstrap admin user on backend startup."""
from __future__ import annotations
import pytest
from sqlalchemy import delete, select
from cyclone.auth import bootstrap, users
from cyclone.auth.deps import AUTH_DISABLED
from cyclone.auth.permissions import Role
from cyclone.db import Session as DbSession
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear(monkeypatch):
# Reset the bootstrap-side AUTH_DISABLED flag so tests don't leak state
# into each other. The conftest fixture flips this back to True at the
# start of every test, but bootstrap.run() mutates this module-level
# value, so we restore it here too.
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
# conftest sets CYCLONE_AUTH_DISABLED=1 at module import. Drop it
# by default so tests exercise the real bootstrap path; the
# AUTH_DISABLED-specific test re-sets it explicitly.
monkeypatch.delenv("CYCLONE_AUTH_DISABLED", raising=False)
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
yield
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
def test_bootstrap_creates_admin_when_users_empty_and_env_set(monkeypatch):
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "firstadmin")
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "firstadminpw1")
bootstrap.run()
with SessionLocal()() as db:
u = db.execute(select(User).where(User.username == "firstadmin")).scalar_one()
assert u.role == Role.ADMIN.value
def test_bootstrap_noop_when_users_exist(monkeypatch):
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "ignored")
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "ignoredignored1")
with SessionLocal()() as db:
users.create(db, username="existing", password="hunter2hunter2", role="admin")
bootstrap.run()
with SessionLocal()() as db:
all_users = db.execute(select(User)).scalars().all()
usernames = {u.username for u in all_users}
assert usernames == {"existing"}
def test_bootstrap_refuses_to_run_without_env(monkeypatch):
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
with pytest.raises(RuntimeError, match="CYCLONE_ADMIN_USERNAME"):
bootstrap.run()
def test_bootstrap_rejects_short_password(monkeypatch):
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "weak")
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "short")
with pytest.raises(RuntimeError, match="12 characters"):
bootstrap.run()
def test_bootstrap_skips_when_auth_disabled(monkeypatch):
monkeypatch.setenv("CYCLONE_AUTH_DISABLED", "1")
# Even without env vars, bootstrap should NOT raise when AUTH_DISABLED=1.
bootstrap.run()
# And it must flip the deps flag so the API skips auth checks.
from cyclone.auth import deps as _deps
assert _deps.AUTH_DISABLED is True
@@ -0,0 +1,81 @@
"""Login rate limit: 5 fails / 5 min per username."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete
from cyclone.api import app
from cyclone.auth import rate_limit, users
from cyclone.db import Session as DbSession
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear():
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
# Reset the in-memory rate-limit counter so a previous test's 5
# failures don't poison this test. The plan recipe calls this out.
rate_limit.reset("victim")
yield
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
rate_limit.reset("victim")
def test_5_fails_then_429():
with SessionLocal()() as db:
users.create(db, username="victim", password="hunter2hunter2", role="user")
client = TestClient(app)
for _ in range(5):
r = client.post(
"/api/auth/login",
json={"username": "victim", "password": "WRONG"},
)
assert r.status_code == 401
# 6th should be 429.
r = client.post(
"/api/auth/login",
json={"username": "victim", "password": "WRONG"},
)
assert r.status_code == 429
assert "Retry-After" in r.headers
# And the module-level helper confirms we're now over the threshold.
assert rate_limit.check("victim") > 0
def test_successful_login_resets_counter():
with SessionLocal()() as db:
users.create(db, username="victim", password="hunter2hunter2", role="user")
client = TestClient(app)
for _ in range(4):
r = client.post(
"/api/auth/login",
json={"username": "victim", "password": "WRONG"},
)
assert r.status_code == 401
# Successful login — should reset the counter.
r = client.post(
"/api/auth/login",
json={"username": "victim", "password": "hunter2hunter2"},
)
assert r.status_code == 200
# Counter reset — 5 more fails allowed.
for _ in range(5):
r = client.post(
"/api/auth/login",
json={"username": "victim", "password": "WRONG"},
)
assert r.status_code == 401
# 6th is now throttled.
r = client.post(
"/api/auth/login",
json={"username": "victim", "password": "WRONG"},
)
assert r.status_code == 429
+111
View File
@@ -0,0 +1,111 @@
"""API tests for /api/auth/login, /api/auth/logout, /api/auth/me."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete
from cyclone.api import app
from cyclone.auth import users
from cyclone.db import Session as DbSession
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear():
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def seeded_admin(client):
with SessionLocal()() as db:
users.create(db, username="admin", password="adminpassword1", role="admin")
return client
def test_login_success_returns_user_and_cookie(client):
with SessionLocal()() as db:
users.create(db, username="alice", password="hunter2hunter2", role="user")
resp = client.post(
"/api/auth/login",
json={"username": "alice", "password": "hunter2hunter2"},
)
assert resp.status_code == 200
body = resp.json()
assert body["username"] == "alice"
assert body["role"] == "user"
assert "password_hash" not in body
assert "cyclone_session" in resp.cookies
def test_login_bad_password_returns_401(client):
with SessionLocal()() as db:
users.create(db, username="bob", password="hunter2hunter2", role="user")
resp = client.post(
"/api/auth/login",
json={"username": "bob", "password": "WRONG"},
)
assert resp.status_code == 401
assert resp.json()["error"] == "invalid_credentials"
def test_login_unknown_user_returns_401(client):
resp = client.post(
"/api/auth/login",
json={"username": "ghost", "password": "whatever"},
)
assert resp.status_code == 401
assert resp.json()["error"] == "invalid_credentials"
def test_login_disabled_user_returns_403(client):
with SessionLocal()() as db:
u = users.create(db, username="carol", password="hunter2hunter2", role="user")
users.disable(db, u.id)
resp = client.post(
"/api/auth/login",
json={"username": "carol", "password": "hunter2hunter2"},
)
assert resp.status_code == 403
assert resp.json()["error"] == "account_disabled"
def test_logout_clears_session_and_cookie(seeded_admin):
login = seeded_admin.post(
"/api/auth/login",
json={"username": "admin", "password": "adminpassword1"},
)
cookie = login.cookies.get("cyclone_session")
assert cookie
resp = seeded_admin.post("/api/auth/logout", cookies={"cyclone_session": cookie})
assert resp.status_code == 204
def test_me_returns_current_user(seeded_admin):
login = seeded_admin.post(
"/api/auth/login",
json={"username": "admin", "password": "adminpassword1"},
)
cookie = login.cookies.get("cyclone_session")
resp = seeded_admin.get("/api/auth/me", cookies={"cyclone_session": cookie})
assert resp.status_code == 200
assert resp.json()["username"] == "admin"
def test_me_without_cookie_returns_401(seeded_admin):
resp = seeded_admin.get("/api/auth/me")
assert resp.status_code == 401
+86
View File
@@ -0,0 +1,86 @@
"""Unit tests for cyclone.auth.sessions — Session create/validate/expire/touch."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import delete
from cyclone.auth import sessions, users
from cyclone.db import Session as DbSession
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear():
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
def _make_user():
with SessionLocal()() as db:
return users.create(db, username="sessuser", password="hunter2hunter2", role="user")
def test_create_session_returns_id_and_session():
user = _make_user()
with SessionLocal()() as db:
sid, sess = sessions.create(db, user_id=user.id)
assert len(sid) >= 32
assert sess.user_id == user.id
assert sess.expires_at > datetime.now(timezone.utc)
def test_get_valid_returns_session_for_active():
user = _make_user()
with SessionLocal()() as db:
sid, _ = sessions.create(db, user_id=user.id)
with SessionLocal()() as db:
got = sessions.get_valid(db, sid)
assert got is not None
assert got.user_id == user.id
def test_get_valid_returns_none_for_missing():
with SessionLocal()() as db:
assert sessions.get_valid(db, "does-not-exist") is None
def test_get_valid_returns_none_for_expired():
user = _make_user()
with SessionLocal()() as db:
sid, _ = sessions.create(db, user_id=user.id)
with SessionLocal()() as db:
sess = sessions.get_valid(db, sid)
sess.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
db.commit()
with SessionLocal()() as db:
assert sessions.get_valid(db, sid) is None
def test_delete_removes_session():
user = _make_user()
with SessionLocal()() as db:
sid, _ = sessions.create(db, user_id=user.id)
sessions.delete(db, sid)
with SessionLocal()() as db:
assert sessions.get_valid(db, sid) is None
def test_touch_extends_expiry():
user = _make_user()
with SessionLocal()() as db:
sid, sess = sessions.create(db, user_id=user.id)
original_expiry = sess.expires_at
sessions.touch(db, sid)
with SessionLocal()() as db:
refreshed = sessions.get_valid(db, sid)
assert refreshed.expires_at >= original_expiry
+106
View File
@@ -0,0 +1,106 @@
"""Unit tests for cyclone.auth.users — User CRUD + bcrypt hashing."""
from __future__ import annotations
import pytest
from sqlalchemy import delete
from sqlalchemy.exc import IntegrityError
from cyclone.auth import users
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear_users():
with SessionLocal()() as db:
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(User))
db.commit()
def test_hash_password_returns_bcrypt():
h = users.hash_password("hunter2hunter2")
assert h.startswith("$2")
def test_hash_password_produces_unique_salts():
a = users.hash_password("same-password")
b = users.hash_password("same-password")
assert a != b
def test_verify_password_correct():
h = users.hash_password("hunter2hunter2")
assert users.verify_password("hunter2hunter2", h) is True
def test_verify_password_incorrect():
h = users.hash_password("hunter2hunter2")
assert users.verify_password("WRONG", h) is False
def test_create_user_persists_with_hashed_password():
with SessionLocal()() as db:
u = users.create(db, username="alice", password="hunter2hunter2", role="admin")
assert u.id is not None
assert u.username == "alice"
assert u.role == "admin"
assert u.disabled_at is None
assert u.password_hash != "hunter2hunter2"
assert u.password_hash.startswith("$2")
def test_create_user_rejects_duplicate_username():
with SessionLocal()() as db:
users.create(db, username="bob", password="hunter2hunter2", role="user")
with SessionLocal()() as db:
with pytest.raises(IntegrityError):
users.create(db, username="bob", password="anotherone", role="user")
def test_get_by_username_returns_user():
with SessionLocal()() as db:
users.create(db, username="carol", password="hunter2hunter2", role="viewer")
with SessionLocal()() as db:
u = users.get_by_username(db, "carol")
assert u is not None
assert u.username == "carol"
def test_get_by_username_returns_none_for_missing():
with SessionLocal()() as db:
assert users.get_by_username(db, "ghost") is None
def test_disable_user_sets_disabled_at():
with SessionLocal()() as db:
u = users.create(db, username="dave", password="hunter2hunter2", role="user")
users.disable(db, u.id)
with SessionLocal()() as db:
refreshed = users.get_by_username(db, "dave")
assert refreshed.disabled_at is not None
def test_to_public_shape_omits_password_hash():
with SessionLocal()() as db:
u = users.create(db, username="eve", password="hunter2hunter2", role="admin")
shape = users.to_public(u)
assert "password_hash" not in shape
assert shape["username"] == "eve"
assert shape["role"] == "admin"
assert "id" in shape and "createdAt" in shape
def test_update_password_actually_rehashes_and_verifies():
with SessionLocal()() as db:
u = users.create(db, username="frank", password="hunter2hunter2", role="user")
with SessionLocal()() as db:
users.update_password(db, u.id, "newpassword1")
# Old password no longer verifies.
with SessionLocal()() as db:
refreshed = users.get_by_username(db, "frank")
assert not users.verify_password("hunter2hunter2", refreshed.password_hash)
assert users.verify_password("newpassword1", refreshed.password_hash)
+128
View File
@@ -0,0 +1,128 @@
"""CLI: python -m cyclone users {create,list,disable,reset-password,set-role}.
Uses Click's CliRunner (matches the existing parse-837/parse-835 CLI tests).
The full CLI group lives in ``cyclone.cli.main`` — we exercise the
``users`` subgroup through the top-level ``main`` so the wiring is real.
"""
from __future__ import annotations
import pytest
from sqlalchemy import delete, select
from cyclone.auth import users
from cyclone.cli import main as cli_main
from cyclone.db import Session as DbSession
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear():
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
def test_create_user_via_cli():
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(
cli_main,
[
"users", "create", "cli-user",
"--role", "viewer",
"--password", "clipassword1",
],
input="", # don't prompt
)
assert result.exit_code == 0, result.output
with SessionLocal()() as db:
u = users.get_by_username(db, "cli-user")
assert u is not None
assert u.role == "viewer"
def test_list_users_via_cli():
from click.testing import CliRunner
with SessionLocal()() as db:
users.create(db, username="listed", password="hunter2hunter2", role="user")
runner = CliRunner()
result = runner.invoke(cli_main, ["users", "list"])
assert result.exit_code == 0, result.output
assert "listed" in result.output
def test_disable_user_via_cli():
from click.testing import CliRunner
with SessionLocal()() as db:
users.create(db, username="todie", password="hunter2hunter2", role="user")
runner = CliRunner()
result = runner.invoke(cli_main, ["users", "disable", "todie"])
assert result.exit_code == 0, result.output
with SessionLocal()() as db:
u = users.get_by_username(db, "todie")
assert u.disabled_at is not None
def test_reset_password_via_cli():
from click.testing import CliRunner
with SessionLocal()() as db:
users.create(db, username="pwchange", password="oldpassword1", role="user")
runner = CliRunner()
result = runner.invoke(
cli_main,
[
"users", "reset-password", "pwchange",
"--password", "newpassword1",
],
)
assert result.exit_code == 0, result.output
with SessionLocal()() as db:
u = users.get_by_username(db, "pwchange")
assert users.verify_password("newpassword1", u.password_hash)
def test_set_role_via_cli():
from click.testing import CliRunner
with SessionLocal()() as db:
users.create(db, username="promote", password="hunter2hunter2", role="viewer")
runner = CliRunner()
result = runner.invoke(
cli_main,
["users", "set-role", "promote", "--role", "admin"],
)
assert result.exit_code == 0, result.output
with SessionLocal()() as db:
u = users.get_by_username(db, "promote")
assert u.role == "admin"
def test_create_rejects_short_password():
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(
cli_main,
[
"users", "create", "weak",
"--role", "viewer",
"--password", "short",
],
)
# Click surfaces validation failures with a non-zero exit code.
assert result.exit_code != 0, result.output
with SessionLocal()() as db:
rows = db.execute(select(User).where(User.username == "weak")).scalars().all()
assert rows == []
def test_disable_unknown_user_exits_nonzero():
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli_main, ["users", "disable", "ghost"])
assert result.exit_code != 0, result.output
+15 -5
View File
@@ -27,7 +27,8 @@ MT = ZoneInfo("America/Denver")
def test_build_outbound_with_explicit_mt():
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
name = build_outbound_filename("11525703", "837P", now_mt=now)
assert name == "11525703-837P-20260620132243505-1of1.x12"
# HCPF outbound format: tp prefix on the tpid
assert name == "tp11525703-837P-20260620132243505-1of1.x12"
def test_build_outbound_default_extension():
@@ -39,15 +40,17 @@ def test_build_outbound_default_extension():
def test_build_outbound_custom_extension():
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
name = build_outbound_filename("11525703", "837P", ext="txt", now_mt=now)
assert name == "11525703-837P-20260620132243505-1of1.txt"
assert name == "tp11525703-837P-20260620132243505-1of1.txt"
def test_build_outbound_uses_mt_when_no_arg():
# Snapshot test — the timestamp will be very recent; check format only
name = build_outbound_filename("11525703", "837P")
assert OUTBOUND_RE.match(name), name
# tp11525703-837P-YYYYMMDDhhmmssSSS-1of1.x12 — 4 dash-separated parts
parts = name.split("-")
assert len(parts) == 4
assert parts[0] == "tp11525703"
assert len(parts[2]) == 17 # yyyymmddhhmmssSSS
@@ -128,8 +131,9 @@ def test_parse_inbound_rejects_non_x12_ext():
def test_roundtrip_outbound_to_inbound():
# Outbound tpid is bare (no TP); inbound tpid is bare inside TP{...}
# The two regexes use different shapes — round-trip via tpid only.
# Outbound uses tp{...}, inbound uses TP{...} (case differs but both
# prefixes are required). The two regexes use different shapes —
# round-trip via tpid only.
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
out = build_outbound_filename("11525703", "837P", now_mt=now)
assert OUTBOUND_RE.match(out)
@@ -142,13 +146,19 @@ def test_roundtrip_outbound_to_inbound():
def test_is_outbound_filename():
assert is_outbound_filename("11525703-837P-20260620132243505-1of1.x12")
# HCPF outbound always has the lowercase "tp" prefix
assert is_outbound_filename("tp11525703-837P-20260620132243505-1of1.x12")
# Bare tpid (no tp prefix) is no longer a valid outbound filename
assert not is_outbound_filename("11525703-837P-20260620132243505-1of1.x12")
# Uppercase TP prefix is the inbound shape, not outbound
assert not is_outbound_filename("TP11525703-837P-20260620132243505-1of1.x12")
assert not is_outbound_filename("not-a-filename")
def test_is_inbound_filename():
assert is_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.x12")
# Lowercase tp prefix is the outbound shape, not inbound
assert not is_inbound_filename("tp11525703-837P_M019048402-20260520231513488-1of1_999.x12")
assert not is_inbound_filename("11525703-837P-20260620132243505-1of1.x12")
+215 -8
View File
@@ -65,6 +65,21 @@ def test_build_gs_emits_gs_segment_with_hc_functional_id():
assert parts[6] == "1"
def test_build_gs_uses_gs04_yyyymmdd_8_digits():
"""GS-04 must be CCYYMMDD (8 digits) per X12; ISA uses YYMMDD (6).
A 6-digit value like '260622' is rejected by EDI validators with
'Element GS-04 must use CCYYMMDD date format'.
"""
gs = _build_gs("SENDER", "RECEIVER", "1")
parts = gs.rstrip("~").split("*")
# parts[4] = GS-04 date
assert len(parts[4]) == 8, f"expected 8-digit CCYYMMDD, got {parts[4]!r}"
# Must parse as a CCYYMMDD date
from datetime import datetime
datetime.strptime(parts[4], "%Y%m%d")
def test_build_st_emits_837_segment():
st = _build_st("0001")
assert st.startswith("ST*837*0001*005010X222A1~")
@@ -100,12 +115,15 @@ def test_build_nm1_person_entity_splits_first_last():
assert parts[4] == "Jane"
def test_build_per_returns_empty_when_no_contact():
assert _build_per(None, None) == ""
assert _build_per("", "") == ""
def test_build_per_emits_per01_even_with_no_contact():
"""PER is required by X12 Loop 1000A — at least PER01 must be present."""
per = _build_per(None, None)
assert per == "PER*IC~"
per = _build_per("", "")
assert per == "PER*IC~"
def test_build_per_emits_segment_with_contact():
def test_build_per_emits_segment_with_phone_contact():
per = _build_per("Jane Doe", "5551234567")
parts = per.rstrip("~").split("*")
assert parts[0] == "PER"
@@ -115,6 +133,25 @@ def test_build_per_emits_segment_with_contact():
assert parts[4] == "5551234567"
def test_build_per_emits_segment_with_email_contact():
"""When email is given, it wins over phone (HCPF expects email)."""
per = _build_per("Tyler Martinez", None, contact_email="tyler@dzinesco.com")
parts = per.rstrip("~").split("*")
assert parts[0] == "PER"
assert parts[1] == "IC"
assert parts[2] == "Tyler Martinez"
assert parts[3] == "EM"
assert parts[4] == "tyler@dzinesco.com"
def test_build_per_email_takes_precedence_over_phone():
"""If both phone and email are given, email is emitted (PER04)."""
per = _build_per("Tyler", "555-1234", contact_email="t@example.com")
parts = per.rstrip("~").split("*")
assert parts[3] == "EM"
assert parts[4] == "t@example.com"
def test_build_n3_returns_empty_when_no_address():
assert _build_n3(None, None) == ""
assert _build_n3("", "") == ""
@@ -133,12 +170,33 @@ def test_build_hl_emits_segment():
assert hl == "HL*1**20*1~"
def test_build_sbr_emits_segment():
sbr = _build_sbr("18", "M123", "PAYER")
def test_build_sbr_emits_segment_with_correct_slots():
"""SBR01=Payer Responsibility Seq Code (default 'P'),
SBR02=Individual Relationship Code (e.g. '18' for self),
SBR09=Claim Filing Indicator Code (e.g. 'MC' for Medicaid)."""
sbr = _build_sbr("18", "MC")
parts = sbr.rstrip("~").split("*")
assert parts[0] == "SBR"
assert parts[1] == "18"
assert parts[9] == "M123"
# SBR01 — primary
assert parts[1] == "P"
# SBR02 — individual relationship (self = 18)
assert parts[2] == "18"
# SBR09 — claim filing indicator
assert parts[9] == "MC"
# Member ID and payer name do NOT belong in SBR — they live in
# NM109 and NM1*PR.NM103 respectively.
assert "M123" not in sbr
assert "PAYER" not in sbr
def test_build_sbr_defaults_relationship_to_self_and_filing_to_empty():
"""When called with all-None, SBR01/02 fall back to safe defaults
and SBR09 is left empty (the validator's R202 rule will then skip)."""
sbr = _build_sbr(None, None)
parts = sbr.rstrip("~").split("*")
assert parts[1] == "P"
assert parts[2] == "18"
assert parts[9] == ""
def test_build_ref_returns_empty_when_no_value():
@@ -200,6 +258,34 @@ def test_build_clm_emits_clm01_to_clm05():
assert parts[5] == "11:1"
def test_build_clm_emits_clm08_defaulting_to_y():
"""CLM-08 (Benefits Assignment Certification) is required by X12
837P when CLM-07 = 'Y'. Default to 'Y' when the source didn't
capture one (matches what 99% of HCPF files look like).
"""
claim = _stub_claim_header()
clm = _build_clm(claim)
parts = clm.rstrip("~").split("*")
# parts[8] = CLM-08
assert parts[8] == "Y", f"CLM-08 should default to 'Y', got {parts[8]!r}"
def test_build_clm_propagates_captured_clm08():
from cyclone.parsers.models import ClaimHeader
claim = ClaimHeader(
claim_id="CLM-1",
total_charge=Decimal("100.00"),
place_of_service="11",
frequency_code="1",
assignment="Y",
benefits_assignment_certification="N",
release_of_info="Y",
)
clm = _build_clm(claim)
parts = clm.rstrip("~").split("*")
assert parts[8] == "N"
def test_build_ref_g1_returns_empty_when_no_prior_auth():
assert _build_ref_g1(None) == ""
assert _build_ref_g1("") == ""
@@ -242,6 +328,57 @@ def test_build_sv1_emits_procedure_modifiers_charge_units():
assert parts[4] == "1"
def test_build_sv1_emits_sv1_06_and_sv1_07_when_dx_pointer_given():
"""SV1-07 (Diagnosis Code Pointer) is required by X12/HCPF when the
parent claim has an HI segment (i.e. has at least one diagnosis).
Per 005010X222A1, SV1-06 is "Not Used" by the guide and MUST be
empty. Unit basis (UN/MJ/...) goes only in SV1-03.
"""
line = _stub_service_line()
sv1 = _build_sv1(line, dx_pointer="1")
parts = sv1.rstrip("~").split("*")
# parts layout: SV1, comp(SV1-01), charge(02), unit_basis(03),
# units(04), pos(05), ""(06 NOT USED), sv1_07(07)
assert len(parts) == 8, f"expected 8 elements, got {parts}"
# SV1-03 = unit basis
assert parts[3] == "UN"
# SV1-06 = "" (Not Used by 837P guide)
assert parts[6] == "", f"SV1-06 must be empty (Not Used by 837P), got {parts[6]!r}"
# SV1-07 = pointer
assert parts[7] == "1"
def test_build_sv1_omits_sv1_07_when_no_dx_pointer():
"""When the claim has no HI segment, SV1-07 should be empty."""
line = _stub_service_line()
sv1 = _build_sv1(line) # no dx_pointer kwarg
parts = sv1.rstrip("~").split("*")
assert len(parts) == 8
assert parts[6] == "" # SV1-06 still empty
assert parts[7] == "" # SV1-07 empty
def test_build_sv1_matches_goodclaim_layout():
"""Layout must match the known-good reference at docs/goodclaim.x12:
SV1*HC:T1019:U1:KX*125.40*UN*19.00***1~
i.e. 8 fields total: comp, charge, UN, units, '', '', '1'.
"""
from cyclone.parsers.models import Procedure, ServiceLine
line = ServiceLine(
line_number=1,
procedure=Procedure(qualifier="HC", code="T1019", modifiers=["U1", "KX"]),
charge=Decimal("125.40"),
units=Decimal("19.00"),
unit_type="UN",
place_of_service=None,
)
sv1 = _build_sv1(line, dx_pointer="1")
assert sv1 == "SV1*HC:T1019:U1:KX*125.40*UN*19.00***1~"
def test_build_dtp_472_emits_service_date():
assert _build_dtp_472(date(2026, 6, 15)) == "DTP*472*D8*20260615~"
@@ -369,6 +506,76 @@ def test_serialize_837_uses_custom_sender_receiver_ids():
parse(text, _CFG)
def test_serialize_837_emits_per_segment_in_submitter_block():
"""X12 Loop 1000A (Submitter Name) requires a PER segment after
NM1*41. The serializer must emit one even with no contact info
(PER01='IC' is the only required element)."""
claim = _load_claim()
text = serialize_837(claim)
# The first NM1*41 should be followed immediately by a PER segment.
seg_ids = [seg.split("*")[0] for seg in text.split("~") if seg]
nm1_41_idx = seg_ids.index("NM1") # first NM1 is the submitter
assert nm1_41_idx >= 0
# The very next segment must be PER (PER01='IC' is required by spec).
assert seg_ids[nm1_41_idx + 1] == "PER"
per_line = next(seg for seg in text.split("~") if seg.startswith("PER*IC"))
assert per_line.startswith("PER*IC")
def test_serialize_837_per_segment_includes_email_from_kwargs():
"""Passing submitter_contact_email should emit PER*IC*<name>*EM*<email>."""
claim = _load_claim()
text = serialize_837(
claim,
sender_id="DZINESCO",
submitter_name="Dzinesco",
submitter_contact_name="Tyler Martinez",
submitter_contact_email="tyler@dzinesco.com",
)
assert "PER*IC*Tyler Martinez*EM*tyler@dzinesco.com" in text
# And the ISA sender id should be the clearhouse TPID, not "CYCLONE".
assert "ZZ*DZINESCO" in text
assert "ZZ*CYCLONE" not in text
def test_serialize_837_sbr09_uses_claim_filing_indicator_code_kwarg():
"""SBR09 must be the claim filing indicator (e.g. 'MC' for Medicaid),
not the member id. The serializer takes it from the kwarg."""
claim = _load_claim()
text = serialize_837(claim, claim_filing_indicator_code="MC")
sbr_line = next(seg for seg in text.split("~") if seg.startswith("SBR*"))
parts = sbr_line.rstrip("~").split("*")
# SBR01 = P (primary), SBR02 = 18 (self), SBR09 = MC
assert parts[1] == "P"
assert parts[2] == "18"
assert parts[9] == "MC"
# And the member id should NOT be in SBR.
assert claim.subscriber.member_id not in sbr_line
def test_serialize_837_for_resubmit_forwards_kwargs_to_serialize_837():
"""serialize_837_for_resubmit is a thin wrapper — it must forward
clearhouse + payer kwargs so the export endpoint can use it."""
claim = _load_claim()
text = serialize_837_for_resubmit(
claim,
interchange_index=7,
sender_id="DZINESCO",
submitter_name="Dzinesco",
submitter_contact_name="Tyler Martinez",
submitter_contact_email="tyler@dzinesco.com",
receiver_id="COMEDASSISTPROG",
receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM",
claim_filing_indicator_code="MC",
)
assert "ZZ*DZINESCO" in text
assert "ZZ*COMEDASSISTPROG" in text
assert "PER*IC*Tyler Martinez*EM*tyler@dzinesco.com" in text
# Control numbers reflect the resubmit index.
isa = next(seg for seg in text.split("~") if seg.startswith("ISA*"))
assert "000000007" in isa
def test_serialize_error_is_an_exception():
assert issubclass(SerializeError, Exception)
+2 -2
View File
@@ -31,13 +31,13 @@ def sftp_block(tmp_path):
def test_stub_writes_preserving_remote_path(sftp_block, tmp_path):
client = SftpClient(sftp_block)
remote = "/CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-837P-20260620132243505-1of1.x12"
remote = "/CO XIX/PROD/coxix_prod_11525703/FromHPE/tp11525703-837P-20260620132243505-1of1.x12"
target = client.write_file(remote, b"ISA*00*...~IEA*1*1~")
assert target.exists()
assert target.read_bytes() == b"ISA*00*...~IEA*1*1~"
# Confirm the full nested MFT path is preserved under staging
rel = target.relative_to(sftp_block.staging_dir)
assert str(rel) == "CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-837P-20260620132243505-1of1.x12"
assert str(rel) == "CO XIX/PROD/coxix_prod_11525703/FromHPE/tp11525703-837P-20260620132243505-1of1.x12"
def test_stub_creates_parent_dirs(sftp_block):
+47 -64
View File
@@ -44,72 +44,21 @@ wheels = [
[[package]]
name = "bcrypt"
version = "5.0.0"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/ae/3af7d006aacf513975fd1948a6b4d6f8b4a307f8a244e1a3d3774b297aad/bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd", size = 25498, upload-time = "2022-10-09T15:36:49.775Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
{ url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" },
{ url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" },
{ url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" },
{ url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" },
{ url = "https://files.pythonhosted.org/packages/78/d4/3b2657bd58ef02b23a07729b0df26f21af97169dbd0b5797afa9e97ebb49/bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f", size = 473446, upload-time = "2022-10-09T15:36:25.481Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0a/1582790232fef6c2aa201f345577306b8bfe465c2c665dec04c86a016879/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0", size = 583044, upload-time = "2022-10-09T15:37:09.447Z" },
{ url = "https://files.pythonhosted.org/packages/41/16/49ff5146fb815742ad58cafb5034907aa7f166b1344d0ddd7fd1c818bd17/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410", size = 583189, upload-time = "2022-10-09T15:37:10.69Z" },
{ url = "https://files.pythonhosted.org/packages/aa/48/fd2b197a9741fa790ba0b88a9b10b5e88e62ff5cf3e1bc96d8354d7ce613/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344", size = 593473, upload-time = "2022-10-09T15:36:27.195Z" },
{ url = "https://files.pythonhosted.org/packages/7d/50/e683d8418974a602ba40899c8a5c38b3decaf5a4d36c32fc65dce454d8a8/bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a", size = 593249, upload-time = "2022-10-09T15:36:28.481Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a7/ee4561fd9b78ca23c8e5591c150cc58626a5dfb169345ab18e1c2c664ee0/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3", size = 583586, upload-time = "2022-10-09T15:37:11.962Z" },
{ url = "https://files.pythonhosted.org/packages/64/fe/da28a5916128d541da0993328dc5cf4b43dfbf6655f2c7a2abe26ca2dc88/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2", size = 593659, upload-time = "2022-10-09T15:36:30.049Z" },
{ url = "https://files.pythonhosted.org/packages/dd/4f/3632a69ce344c1551f7c9803196b191a8181c6a1ad2362c225581ef0d383/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535", size = 613116, upload-time = "2022-10-09T15:37:14.107Z" },
{ url = "https://files.pythonhosted.org/packages/87/69/edacb37481d360d06fc947dab5734aaf511acb7d1a1f9e2849454376c0f8/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e", size = 624290, upload-time = "2022-10-09T15:36:31.251Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ca/6a534669890725cbb8c1fb4622019be31813c8edaa7b6d5b62fc9360a17e/bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab", size = 159428, upload-time = "2022-10-09T15:36:32.893Z" },
{ url = "https://files.pythonhosted.org/packages/46/81/d8c22cd7e5e1c6a7d48e41a1d1d46c92f17dae70a54d9814f746e6027dec/bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9", size = 152930, upload-time = "2022-10-09T15:36:34.635Z" },
]
[[package]]
@@ -377,9 +326,12 @@ name = "cyclone"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "bcrypt" },
{ name = "click" },
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "keyring" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "pydantic" },
{ name = "python-multipart" },
{ name = "pyyaml" },
@@ -393,6 +345,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-randomly" },
]
sftp = [
{ name = "paramiko" },
@@ -403,15 +356,19 @@ sqlcipher = [
[package.metadata]
requires-dist = [
{ name = "bcrypt", specifier = "<4.1" },
{ name = "click", specifier = ">=8.1,<9" },
{ name = "cryptography", specifier = ">=49.0,<50" },
{ name = "fastapi", specifier = ">=0.110,<1" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27,<1" },
{ name = "keyring", specifier = ">=25.0,<26" },
{ name = "paramiko", marker = "extra == 'sftp'", specifier = ">=3.4,<6" },
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
{ name = "pydantic", specifier = ">=2.6,<3" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23,<1" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
{ name = "pytest-randomly", marker = "extra == 'dev'", specifier = ">=4.1" },
{ name = "python-multipart", specifier = ">=0.0.9,<1" },
{ name = "pyyaml", specifier = ">=6.0,<7" },
{ name = "sqlalchemy", specifier = ">=2.0,<3" },
@@ -714,6 +671,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" },
]
[[package]]
name = "passlib"
version = "1.7.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
]
[package.optional-dependencies]
bcrypt = [
{ name = "bcrypt" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -935,6 +906,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "pytest-randomly"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/27/b3/36192dacc0f470ac2cc516f73e01739c9a48a8224f76beada4f85e1c8a89/pytest_randomly-4.1.0.tar.gz", hash = "sha256:47f1d9746c3bc3efabd53ae1ebfb8bb385cf3d4df4b505b6d58d9c97a3dfe70f", size = 14302, upload-time = "2026-04-20T13:01:51.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl", hash = "sha256:f55e89e53367b090c0c053697d7f9d77595543d0e0516c93978b50c0f6b252f9", size = 8353, upload-time = "2026-04-20T13:01:50.382Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
+1
View File
@@ -0,0 +1 @@
ISA*00* *00* *ZZ*11525703 *ZZ*COMEDASSISTPROG*240911*2240*^*00501*240901088*1*P*:~GS*HC*11525703*COMEDASSISTPROG*20240911*224042*240007724*X*005010X222A1~ST*837*24007724*005010X222A1~BHT*0019*00*s9911i2440g11525703d*20240911*224042*CH~NM1*41*2*Dzinesco*****46*11525703~PER*IC*Tyler Martinez*EM*tmartinez@gmail.com~NM1*40*2*COLORADO MEDICAL ASSISTANCE PROGRAM*****46*COMEDASSISTPROG~HL*1**20*1~PRV*BI*PXC*251E00000X~NM1*85*2*TOC, Inc.*****XX*1881068062~N3*1100 East Main St*Suite A~N4*Montrose*CO*814014063~REF*EI*721587149~HL*2*1*22*0~SBR*P*18*******MC~NM1*IL*1*Phillips*Esther ****MI*J851806~N3*579 NORWOOD RD~N4*Montrose*CO*814034628~DMG*D8*19390307*F~NM1*PR*2*CO_TXIX*****PI*CO_TXIX~CLM*t991102440o1c79d*595.32***12:B:1*Y*A*Y*Y~REF*G1*2~HI*ABK:R69~LX*1~SV1*HC:T1019:U1:KX*125.40*UN*19.00***1~DTP*472*D8*20240902~REF*6R*t991102440v587348d~LX*2~SV1*HC:T1019:U1:KX*123.20*UN*18.67***1~DTP*472*D8*20240903~REF*6R*t991102440v587638d~LX*3~SV1*HC:T1019:U1:KX*123.64*UN*18.73***1~DTP*472*D8*20240904~REF*6R*t991102440v587903d~LX*4~SV1*HC:T1019:U1:KX*123.64*UN*18.73***1~DTP*472*D8*20240905~REF*6R*t991102440v588158d~LX*5~SV1*HC:T1019:U1:KX*99.44*UN*15.07***1~DTP*472*D8*20240906~REF*6R*t991102440v588441d~SE*42*24007724~GE*1*240007724~IEA*1*240901088~
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,798 @@
# Parse → Detect → Decide: 837P/835 Upload Workflow
**Date:** 2026-06-21
**Branch:** `claims-unique-fix` (worktree)
**Status:** Draft (brainstorming approved, awaiting writing-plans)
**Supersedes:** `2026-06-21-cyclone-claims-unique-constraint-and-409-ux-design.md` (kept for the migration 0013 + store helper sections, which still apply).
---
## 1. Why this exists
Today's upload flow is "parse → validate → persist" in a single call.
When a claim's CLM01 collides with a prior batch, the persist raises
`IntegrityError`, the transaction rolls back, and the API returns 409
with **no parse result, no list of colliding claims, and no way to act**.
The user sees only an error message and a `batch_id` that doesn't exist.
This is wrong: the parse already happened. The user should see what was
parsed, see which claims collide with which prior batches, and decide
what to do (force-insert, delete the prior batch, or pick a different
file). The 409 response body today is too thin to make that decision.
The root cause of the 409s is a schema bug — see
`2026-06-21-cyclone-claims-unique-constraint-and-409-ux-design.md` §1.
Migration 0013 drops the `UNIQUE(batch_id, patient_control_number)` inline
constraint that 0003 was supposed to drop. After 0013 lands, **multi-claim
837P files where many CLM segments share a subscriber's `member_id` will
ingest cleanly for the first time**.
But 0013 alone is not enough. The current schema has `claims.id` and
`remittances.id` as single-column PRIMARY KEYs, which means the same
CLM01 cannot exist in two different batches. That makes "cross-batch
CLM01 collisions" impossible to express in the data — but it also makes
resubmits impossible, and it makes the 409-with-collision-summary workflow
this SP describes unreachable. **Migration 0014 (added as Task 1.3 to the
plan) relaxes the PKs to composite `(batch_id, id)`.** After 0014 lands,
real resubmits are representable, the pre-flight 409 path actually fires,
and the workflow defined below is exercisable end-to-end against real data.
This SP defines the workflow for both classes of collision:
1. Multi-claim files with shared `member_id` (no longer a 409 after 0013).
2. Files where one or more CLM01s exist in a prior batch (a 409 after 0014; this SP defines the UX for it).
---
## 2. Operator surface
| Surface | Change |
|---|---|
| Backend | Pre-flight dedup check in `parse_837` and `parse_835`. New `?force=true` query param. New `DELETE /api/batches/{id}` endpoint. 409 body shape changes. |
| Frontend | `Upload.tsx` panel renders the full parse result + collision summary, with actions: "Force insert (skip dups)", "Open prior batch", "Delete prior batch and retry", "Pick a different file". |
| Tests | Migration + store helpers (already done in `claims-unique-fix`). New tests for: pre-flight dedup, force-insert, within-file dup, race 409, DELETE endpoint, frontend panel. |
---
## 3. The workflow
### 3.1 No collision (the happy path)
```
User → POST /api/parse-837 (file)
← 200 + ParseResult + batch_id
Batch persisted. UI shows parsed claims and links to the new batch.
```
### 3.2 Collision (the new path)
```
User → POST /api/parse-837 (file)
← 409 + {
error: "Duplicate claim",
detail: "...",
existing_batch_id: "B123", # most-recent prior batch with a colliding CLM01
collisions: {
colliding_claim_ids: ["A", "B"],
total_collisions: 2,
total_claims: 141, # claims in the file
new_claims_after_skip: 139, # claims that WOULD be inserted on force
},
parse_result: { ... full ParseResult ... },
}
User sees the parse result in the panel.
User can:
- Click "Force insert (skip 2 dups)" → POST /api/parse-837?force=true (same file)
← 200 + ParseResult + { skipped_claim_ids: ["A", "B"], inserted: 139 }
- Click "Open prior batch" → navigate to /batches/B123
- Click "Delete prior batch" → DELETE /api/batches/B123, then click "Re-upload"
- Click "Pick a different file" → clear the upload state
```
### 3.3 Force-insert after collision
`force=true` skips the pre-flight check. The store's existing per-row
`s.get(Claim, claim_id)` dedup still skips colliding rows silently, so
the new batch persists with only the non-colliding claims. The response
body includes `skipped_claim_ids` so the UI can show what was skipped.
`force=true` does NOT bypass the parser. If the file fails validation
(missing diagnosis, malformed segment), the response is still 422.
### 3.4 Race condition (pre-flight clean, persist fails)
If a pre-flight dedup check finds no collisions, but a concurrent process
ingests a colliding CLM01 between the check and the persist, the persist
will still raise `IntegrityError`. The handler catches it and returns
**the same 409 shape as the pre-flight collision** with
`existing_batch_id` set to the racing batch and `detail` mentioning
"another process ingested this between the check and the persist —
re-upload to retry". The user re-runs the same flow.
---
## 4. Within-file duplicates
If the file itself has the same CLM01 twice (a malformed file, not a
cross-batch collision), the pre-flight check catches it the same way:
it returns 409 with `existing_batch_id: null` and `detail: "CLM01 A
appears twice in this file"`. The user can only force-insert (which
skips the second instance). They can't "delete the prior batch" because
there isn't one — it's a bad file.
---
## 5. The 409 body shape
```json
{
"error": "Duplicate claim",
"detail": "This file (or one previously ingested with the same claim control number) collides with an existing record. 2 of 141 claims collide with batch B123.",
"batch_id": null,
"existing_batch_id": "B123",
"collisions": {
"colliding_claim_ids": ["A", "B"],
"total_collisions": 2,
"total_claims": 141,
"new_claims_after_skip": 139
},
"parse_result": { ... full ParseResult ... }
}
```
Field semantics:
- `error`: short tag for the UI ("Duplicate claim", "Duplicate remittance", "Within-file duplicate CLM01").
- `detail`: human-readable, mentions the count and the existing batch when known.
- `batch_id`: always `null` on 409 (the insert rolled back).
- `existing_batch_id`: the most-recent prior batch that contains a colliding CLM01, or `null` if (a) the collision is within-file, or (b) the colliding claim has since been deleted (race).
- `collisions.colliding_claim_ids`: subset of `parse_result.claims[].claim_id` that collides.
- `collisions.total_claims`: count from `parse_result.summary.total_claims`.
- `collisions.new_claims_after_skip`: `total_claims - total_collisions`.
- `parse_result`: the full `ParseResult` (same shape as a 200 response body). The UI uses this to render the parsed claims list.
The 200 body on `force=true` adds `skipped_claim_ids: ["A", "B"]` at the top level so the UI can show a "skipped" badge per claim.
---
## 6. `DELETE /api/batches/{id}`
New endpoint. Cascades through `ON DELETE CASCADE` FKs:
```
batches ─┬─ claims ─┬─ matches
│ ├─ activity_events (claim_id)
│ └─ line_reconciliations
├─ remittances ─┬─ cas_adjustments
│ ├─ service_line_payments
│ └─ activity_events (remittance_id)
└─ activity_events (batch_id only)
```
FKs already declare `ON DELETE CASCADE` in the migrations, so the
SQLite engine handles the cascade. The endpoint just needs to
`session.delete(batch_row)` and commit.
The endpoint:
- `204 No Content` on success.
- `404 Not Found` if the batch doesn't exist.
- `409 Conflict` if the batch has any claims in a non-`submitted` state
(e.g., `paid`, `reversed`, `denied`). Forces the user to first
unreconcile — same as the existing 409 pattern for `manual_match` /
`manual_unmatch` (see `store.py:AlreadyMatchedError`).
A `batch_deleted` activity event is recorded before the delete so the
audit log has a tombstone. The event's `batch_id` will be `null` after
the cascade (the FK is to `batches.id` with no `ON DELETE` clause
specified in any migration; verify in `migrations/0001_initial.sql`
the spec says we preserve audit history). If the FK is `ON DELETE
CASCADE`, we record the event AFTER the cascade with `batch_id` set to
the deleted id and rely on the cascade to remove it (acceptable, or we
use a no-cascade FK and keep the tombstone). **Open question resolved
during implementation by reading the actual FK clauses.**
---
## 7. Backend implementation
### 7.1 New dedup helper
`backend/src/cyclone/store.py` (already added in `claims-unique-fix`):
```python
def find_existing_batch_for_claim(claim_id: str) -> str | None:
"""Return the batch_id of the first batch containing this claim id, or None.
Pure read; opens a short-lived session. Used by the 837 409 handler to
surface which prior batch already holds the same CLM01.
Returns the most-recent batch (ORDER BY parsed_at DESC LIMIT 1) so the
UI links to the most likely "where did the dup come from" answer.
"""
from sqlalchemy import select
from cyclone.db import Claim
with db.SessionLocal()() as s:
row = s.execute(
select(Claim.batch_id)
.where(Claim.id == claim_id)
.order_by(Claim.state_changed_at.desc()) # most-recent touch
.limit(1)
).first()
return row[0] if row else None
def find_existing_batch_for_remit(remit_id: str) -> str | None:
"""Same shape as find_existing_batch_for_claim but for remittances."""
from sqlalchemy import select
from cyclone.db import Remittance
with db.SessionLocal()() as s:
row = s.execute(
select(Remittance.batch_id)
.where(Remittance.id == remit_id)
.order_by(Remittance.received_at.desc())
.limit(1)
).first()
return row[0] if row else None
```
The current `claims-unique-fix` implementation uses
`select(Claim.batch_id).where(Claim.id == claim_id).limit(1)` without
`ORDER BY`. We replace it with the ordered version to satisfy
"return the most-recent colliding batch".
### 7.2 New pre-flight dedup check
`backend/src/cyclone/dedup.py` (new file, single responsibility):
```python
"""Pre-flight dedup for parsed 837P/835 batches.
Splits the parsed result into "would-insert" and "would-skip" sets by
querying the DB for any claim_id / payer_claim_control_number already
present. Also detects within-file duplicates by counting claim_id
frequencies.
Used by the parse-837 and parse-835 endpoints between validation and
persist, so the user can see the parse result + collision summary
before any DB write.
"""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from cyclone import db
from cyclone.db import Claim, Remittance
@dataclass(frozen=True)
class CollisionReport:
"""What the parse endpoint needs to render a 409 response."""
colliding_claim_ids: list[str] # CLM01s (837) or CLP01s (835)
existing_batch_id: str | None # most-recent prior batch with a collision, or None
within_file_duplicate_ids: list[str] # CLM01s appearing twice in this file (subset of colliding_claim_ids)
total_claims: int
def preflight_837(result, session: Session | None = None) -> CollisionReport:
"""Detect 837 collisions: within-file dupes + cross-batch CLM01 dupes."""
claim_ids = [c.claim_id for c in result.claims]
counts = Counter(claim_ids)
within_file_duplicate_ids = sorted(
cid for cid, n in counts.items() if n > 1
)
seen: set[str] = set(claim_ids)
if not seen:
return CollisionReport(
colliding_claim_ids=[],
existing_batch_id=None,
within_file_duplicate_ids=[],
total_claims=0,
)
own_session = session is None
if own_session:
session = db.SessionLocal()()
try:
rows = session.execute(
select(Claim.id, Claim.batch_id)
.where(Claim.id.in_(seen))
.order_by(Claim.state_changed_at.desc())
).all()
finally:
if own_session:
session.close()
db_collisions = {cid: bid for cid, bid in rows}
colliding = sorted(cid for cid in seen if cid in db_collisions)
existing_batch_id = next(iter(db_collisions.values()), None) if db_collisions else None
return CollisionReport(
colliding_claim_ids=colliding,
existing_batch_id=existing_batch_id,
within_file_duplicate_ids=within_file_duplicate_ids,
total_claims=len(claim_ids),
)
def preflight_835(result, session: Session | None = None) -> CollisionReport:
"""Same shape for 835 remittances. Payer claim control number = CLP01 = remittance.id."""
pcns = [c.payer_claim_control_number for c in result.claims]
counts = Counter(pcns)
within_file_duplicate_ids = sorted(p for p, n in counts.items() if n > 1)
seen = set(pcns)
if not seen:
return CollisionReport(
colliding_claim_ids=[],
existing_batch_id=None,
within_file_duplicate_ids=[],
total_claims=0,
)
own_session = session is None
if own_session:
session = db.SessionLocal()()
try:
rows = session.execute(
select(Remittance.id, Remittance.batch_id)
.where(Remittance.id.in_(seen))
.order_by(Remittance.received_at.desc())
).all()
finally:
if own_session:
session.close()
db_collisions = {pcn: bid for pcn, bid in rows}
colliding = sorted(pcn for pcn in seen if pcn in db_collisions)
existing_batch_id = next(iter(db_collisions.values()), None) if db_collisions else None
return CollisionReport(
colliding_claim_ids=colliding,
existing_batch_id=existing_batch_id,
within_file_duplicate_ids=within_file_duplicate_ids,
total_claims=len(pcns),
)
```
### 7.3 Modified `parse_837` endpoint
```python
@app.post("/api/parse-837")
async def parse_837(
request: Request,
file: UploadFile = File(...),
payer: str = Query("co_medicaid"),
include_raw_segments: bool = Query(True),
strict: bool = Query(False),
ack: bool = Query(False),
force: bool = Query(False), # NEW
) -> Any:
# ... existing parse + validate ...
if _has_claim_validation_errors(result):
return JSONResponse(status_code=422, content=json.loads(result.model_dump_json()))
# NEW: pre-flight dedup check
if not force and result.claims:
report = dedup.preflight_837(result)
if report.colliding_claim_ids or report.within_file_duplicate_ids:
return _build_409_response(
result=result,
report=report,
error="Duplicate claim",
kind="cross_batch" if report.existing_batch_id else "within_file",
)
# Persist (existing path). On IntegrityError (race), same 409 shape.
rec = BatchRecord(
id=uuid.uuid4().hex,
kind="837p",
input_filename=file.filename or "upload.txt",
parsed_at=utcnow(),
result=result,
)
try:
store.add(rec, event_bus=request.app.state.event_bus)
except IntegrityError as exc:
# Race: pre-flight said clean, but persist hit a PK. Re-run pre-flight
# so the 409 body has the same shape.
report = dedup.preflight_837(result)
return _build_409_response(
result=result,
report=report,
error="Duplicate claim (race condition)",
kind="race",
)
# ... existing response ...
if _client_wants_json(request):
body = json.loads(result.model_dump_json())
if ack:
ack_body = _build_and_persist_ack(rec.id)
if ack_body is not None:
body["ack"] = ack_body
# If force=true, the store.add silently skipped some claims.
# Surface what was skipped so the UI can show a "skipped" badge.
if force:
body["skipped_claim_ids"] = sorted({
c.claim_id for c in result.claims
if _claim_skipped(c.claim_id, rec.id)
})
return JSONResponse(content=body)
# ... streaming response ...
```
Where:
```python
def _build_409_response(
result, report, error: str, kind: str
) -> JSONResponse:
"""Build the standard 409 body for any dedup failure."""
if kind == "cross_batch":
detail = (
f"{len(report.colliding_claim_ids)} of {report.total_claims} "
f"claims collide with prior batch {report.existing_batch_id}. "
f"Force-insert to skip the duplicates, or delete the prior batch."
)
elif kind == "within_file":
detail = (
f"CLM01(s) {', '.join(report.within_file_duplicate_ids)} appear "
f"twice in this file. Force-insert will keep the first occurrence "
f"and skip the rest."
)
else: # race
detail = (
f"Another process ingested a colliding batch between the check "
f"and the persist. Re-upload to retry with the latest state."
)
body = {
"error": error,
"detail": detail,
"batch_id": None,
"existing_batch_id": report.existing_batch_id,
"collisions": {
"colliding_claim_ids": report.colliding_claim_ids,
"total_collisions": len(report.colliding_claim_ids),
"total_claims": report.total_claims,
"new_claims_after_skip": report.total_claims - len(report.colliding_claim_ids),
},
"parse_result": json.loads(result.model_dump_json()),
}
return JSONResponse(status_code=409, content=body)
```
`force=true` does NOT bypass validation (still 422 for bad data). It
only bypasses the pre-flight dedup. The `store.add` dedup still skips
colliding claims silently, but the response surfaces the skip list.
### 7.4 Modified `parse_835` endpoint
Same pattern, with `dedup.preflight_835` and the 835 parse result. Not
shown in detail; the structure mirrors 837.
### 7.5 New `DELETE /api/batches/{id}`
```python
@app.delete("/api/batches/{batch_id}")
def delete_batch(batch_id: str) -> dict:
"""Hard-delete a batch and all its child rows.
Returns 204 on success, 404 if missing, 409 if the batch has any
claims/remits in a non-`submitted` state (must unreconcile first).
"""
from cyclone import db
with db.SessionLocal()() as s:
batch = s.get(db.Batch, batch_id)
if batch is None:
raise HTTPException(404, f"Batch {batch_id} not found")
# Refuse if any claim/remittance is past 'submitted' state
non_submitted = s.execute(
select(db.Claim.id)
.where(db.Claim.batch_id == batch_id)
.where(db.Claim.state != "submitted")
.limit(1)
).first()
if non_submitted is not None:
raise HTTPException(
409,
f"Batch {batch_id} has claims in non-submitted state; "
f"unreconcile first before deleting.",
)
# Record tombstone activity event before the cascade
s.add(db.ActivityEvent(
ts=utcnow(),
kind="batch_deleted",
batch_id=batch_id,
payload_json={"message": f"Batch {batch_id} deleted"},
))
s.flush()
s.delete(batch)
s.commit()
return {"ok": True, "batch_id": batch_id}
```
The FKs in the schema (`migrations/0001_initial.sql` and later) declare
`ON DELETE CASCADE` on `claims.batch_id`, `remittances.batch_id`, etc.
SQLite handles the cascade at the engine level. We verify this assumption
in the implementation test by deleting a batch with child rows and
asserting the child rows are gone.
---
## 8. Frontend
### 8.1 `src/lib/api.ts`
`ApiError` carries more collision data:
```typescript
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public existingBatchId: string | null = null,
public collisions: CollisionSummary | null = null,
public parseResult: unknown = null,
) {
super(message);
}
}
export type CollisionSummary = {
colliding_claim_ids: string[];
total_collisions: number;
total_claims: number;
new_claims_after_skip: number;
};
```
`parse837` adds `?force=true` to the URL when called for the
"force-insert" action:
```typescript
export async function parse837(
file: File,
options: { onProgress?: (p: number) => void; force?: boolean } = {},
): Promise<ParseResult> {
const url = `${base}/api/parse-837${options.force ? "?force=true" : ""}`;
// ... existing fetch + body parse ...
if (!res.ok) {
const { message, existingBatchId, collisions, parseResult } = await readErrorBody(res);
throw new ApiError(res.status, message, existingBatchId, collisions, parseResult);
}
return res.json();
}
```
### 8.2 `src/pages/Upload.tsx`
New state:
```typescript
type UploadError = {
kind: "duplicate";
existingBatchId: string | null;
collisions: CollisionSummary;
parseResult: ParseResult;
filename: string;
};
const [uploadError, setUploadError] = useState<UploadError | null>(null);
const [forceInserting, setForceInserting] = useState(false);
```
Panel JSX (above the streaming results):
```tsx
{uploadError ? (
<div
role="alert"
className="rounded-md border border-destructive/40 bg-destructive/5 p-4 mx-auto max-w-3xl"
>
<div className="flex items-center gap-2">
<span className="inline-flex items-center rounded-md bg-destructive px-2 py-0.5 text-xs font-semibold text-destructive-foreground">
409
</span>
<span className="font-semibold">
{uploadError.collisions.total_collisions} of {uploadError.collisions.total_claims} claims
collide
{uploadError.existingBatchId
? ` with batch ${uploadError.existingBatchId}`
: " within this file"}
</span>
</div>
<p className="mt-2 text-sm text-muted-foreground">
File <span className="font-mono">{uploadError.filename}</span> would persist
{" "}{uploadError.collisions.new_claims_after_skip} of {uploadError.collisions.total_claims} claims.
Colliding CLM01s: {uploadError.collisions.colliding_claim_ids.join(", ")}.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button
disabled={forceInserting}
onClick={async () => {
setForceInserting(true);
try {
// re-call with force=true; the response will be 200 + skipped_claim_ids
const result = await parse837(file, { onProgress: () => {}, force: true });
setParseResult(result);
setUploadError(null);
toast.success(
`Force-inserted: ${result.summary.total_claims - (result.skipped_claim_ids?.length ?? 0)} of ${result.summary.total_claims} claims (skipped ${result.skipped_claim_ids?.length ?? 0} dups)`,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Force-insert failed");
} finally {
setForceInserting(false);
}
}}
>
Force insert (skip {uploadError.collisions.total_collisions} dups)
</Button>
{uploadError.existingBatchId ? (
<>
<Button variant="outline" onClick={() => navigate(`/batches/${uploadError.existingBatchId}`)}>
Open prior batch
</Button>
<Button
variant="outline"
onClick={async () => {
if (!confirm(`Delete batch ${uploadError.existingBatchId}? This cannot be undone.`)) return;
await deleteBatch(uploadError.existingBatchId);
toast.success(`Deleted ${uploadError.existingBatchId}`);
setUploadError(null);
pickFile(null);
}}
>
Delete prior batch
</Button>
</>
) : null}
<Button variant="ghost" onClick={() => { setUploadError(null); pickFile(null); }}>
Pick a different file
</Button>
</div>
{/* The full parse result is rendered below so the user can see what was parsed. */}
<details className="mt-3 text-sm">
<summary>Show parsed claims ({uploadError.parseResult.claims.length})</summary>
<pre className="mt-2 max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
{JSON.stringify(uploadError.parseResult.summary, null, 2)}
</pre>
</details>
</div>
) : null}
```
---
## 9. Database
Migration 0013 already exists on the `claims-unique-fix` worktree. It
drops the `UNIQUE(batch_id, patient_control_number)` inline constraint.
After it runs:
- The 409 fires only on actual CLM01 collisions (not the `member_id`
dedup that was over-constraining before).
- Multi-claim 837P files with shared `member_id` ingest cleanly for
the first time.
Migration 0014 (added as Task 1.3 in the plan) further relaxes the schema:
it changes the PKs on `claims` and `remittances` from single-column
(`id`) to composite (`batch_id`, `id`). This is what allows resubmits and
makes the workflow in §3 reachable.
No new tables. No new columns. The DELETE endpoint relies on existing
`ON DELETE CASCADE` FKs.
---
## 10. Files changed
| File | Change |
|---|---|
| `backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql` | new (DONE on `claims-unique-fix`) |
| `backend/src/cyclone/migrations/0014_relax_claims_remits_pk.sql` | new: composite PK `(batch_id, id)` on `claims` and `remittances`; updates FKs |
| `backend/src/cyclone/store.py` | new `find_existing_batch_for_claim` / `find_existing_batch_for_remit` (DONE) + new `delete_batch` method |
| `backend/src/cyclone/dedup.py` | new file: pre-flight `preflight_837` / `preflight_835` + `CollisionReport` dataclass |
| `backend/src/cyclone/api.py` | 837/835 endpoints: pre-flight check, force param, new 409 body, race handler, new DELETE endpoint |
| `src/lib/api.ts` | `ApiError` adds `collisions` + `parseResult`; `parse837`/`parse835` accept `force`; new `deleteBatch` |
| `src/pages/Upload.tsx` | new `UploadError` state, error panel JSX, force-insert handler, delete-prior handler |
| `src/pages/Upload.test.tsx` | new tests (4 cases from §11) |
| `backend/tests/test_db_migrate.py` | 0013 idempotency + UNIQUE-dropped tests (DONE); 0014 composite-PK + FK-cascade tests |
| `backend/tests/test_store.py` | `find_existing_batch_for_claim`/`remit` tests (DONE) |
| `backend/tests/test_dedup.py` | new tests for `preflight_837` / `preflight_835` (§11) |
| `backend/tests/test_api_parse_persists.py` | new tests: pre-flight 409, force-insert, within-file 409, race 409, DELETE endpoint (§11) |
No new dependencies. No config changes.
---
## 11. Test plan
### Backend (pytest)
| Test | File | Asserts |
|---|---|---|
| `test_preflight_837_finds_no_collisions_on_empty_db` | `test_dedup.py` | empty DB → empty `colliding_claim_ids`, no `existing_batch_id` |
| `test_preflight_837_finds_cross_batch_collision` | `test_dedup.py` | pre-seed a claim; pre-flight returns that claim_id in `colliding_claim_ids` and the seeded batch in `existing_batch_id` |
| `test_preflight_837_finds_within_file_duplicate` | `test_dedup.py` | parsed result has the same CLM01 twice; pre-flight returns it in both `colliding_claim_ids` and `within_file_duplicate_ids` |
| `test_preflight_837_returns_most_recent_batch_id` | `test_dedup.py` | pre-seed 3 batches with the same CLM01 at different times; pre-flight returns the most-recent batch_id |
| `test_preflight_835_mirrors_837` | `test_dedup.py` | same shape for remittances |
| `test_parse_837_409_includes_parse_result_and_collisions` | `test_api_parse_persists.py` | pre-seed a claim; upload a file with a colliding CLM01; assert 409 with `parse_result`, `collisions.colliding_claim_ids`, `existing_batch_id` |
| `test_parse_837_409_within_file_duplicate_has_null_batch_id` | `test_api_parse_persists.py` | upload a file with the same CLM01 twice; assert 409 with `existing_batch_id: null` and `within_file_duplicate_ids` populated |
| `test_parse_837_force_true_persists_non_colliding_claims` | `test_api_parse_persists.py` | pre-seed a claim; upload a file with 3 claims, 1 colliding; assert 200 with `skipped_claim_ids: [colliding_id]`, the 2 non-colliding claims persist |
| `test_parse_837_force_true_does_not_bypass_validation` | `test_api_parse_persists.py` | a file that fails validation still returns 422 with `force=true` |
| `test_parse_837_race_409_uses_same_body_shape` | `test_api_parse_persists.py` | mock `store.add` to raise IntegrityError; assert 409 body has the same shape as the pre-flight 409 |
| `test_delete_batch_cascades_to_claims` | `test_api_parse_persists.py` | persist a batch with 2 claims; DELETE; assert batch and both claims are gone |
| `test_delete_batch_404_on_unknown` | `test_api_parse_persists.py` | DELETE /api/batches/does-not-exist → 404 |
| `test_delete_batch_409_on_reconciled_claims` | `test_api_parse_persists.py` | persist a batch, mark a claim state='paid'; DELETE → 409 |
| `test_parse_837_after_delete_succeeds` | `test_api_parse_persists.py` | pre-seed a colliding claim; DELETE that batch; re-upload the same file; assert 200 |
### Frontend (vitest)
| Test | File | Asserts |
|---|---|---|
| `test_error_panel_renders_on_409_with_collisions` | `Upload.test.tsx` | mock `parse837` to throw `ApiError(409, ..., PRIOR, collisions, parseResult)`; assert panel visible with all collision data |
| `test_force_insert_button_re_calls_with_force_true` | `Upload.test.tsx` | user clicks "Force insert"; assert `parse837` is called with `{ force: true }` |
| `test_delete_prior_button_calls_deleteBatch` | `Upload.test.tsx` | user clicks "Delete prior batch"; assert `deleteBatch(existingBatchId)` is called |
| `test_pick_different_clears_error` | `Upload.test.tsx` | user clicks "Pick a different file"; assert `uploadError` is cleared and file picker is reset |
| `test_no_panel_on_non_409` | `Upload.test.tsx` | 400 error; assert panel absent |
| `test_within_file_duplicate_omits_prior_batch_actions` | `Upload.test.tsx` | 409 with `existingBatchId: null`; assert "Open prior batch" and "Delete prior batch" buttons are absent |
---
## 12. Out of scope
* Batch editing (update claim state, edit claim fields). Future SP.
* Cross-batch dedup REPORT (a "find all CLM01s in batches B1+B2+B3"
query). Future SP.
* Migration reversibility for 0013 — the recreation preserves data but
not schema history. Acceptable since 0013 just drops an inline
constraint; recreating the constraint would be a separate migration.
* Audit event for force-insert skips. The user explicitly chose to
skip silently; we honor that.
---
## 13. Risk
* **Pre-flight check race**: between the check and the persist, a
concurrent process could ingest a colliding claim. The persist would
then raise `IntegrityError`; the handler returns the same 409 shape
with `detail` mentioning the race. The user re-runs. Acceptable.
* **DELETE on a large batch**: cascade through `claims`, `remittances`,
`matches`, `line_reconciliations`, `activity_events`. SQLite handles
the cascade in a single transaction; a 140-claim batch deletes in
<100ms. The endpoint refuses if any claim is past `submitted` state.
* **`force=true` silent skip**: the user clicks "Force insert" and
the response says "X of Y claims persisted, Z skipped". They
acknowledged this in the panel before clicking. No undo.
* **Within-file duplicates and force-insert**: the user can force-insert
a file with the same CLM01 twice. The first instance persists, the
second is silently skipped. This is intentional — within-file dupes
are usually a typo, and the user has explicitly asked to proceed.
* **`existing_batch_id` may be stale**: the helper returns the
most-recent batch by `state_changed_at` (or `received_at` for 835).
The user clicks "Open prior batch" and the batch may have been
deleted in the meantime. The BatchesList page already handles 404
gracefully.
---
## 14. Rollout
1. **Schema**: migration 0013 applies on next `cyclone` startup.
Idempotent and reversible only by rebuilding the `claims` table
(acceptable; production data preserved by the INSERT...SELECT).
2. **Backend API**: new `force` param + new 409 body shape + new
DELETE endpoint. Existing clients that don't pass `force` see the
same behavior as before for collision-free files. Collision cases
now get a richer 409 body that includes `parse_result`; clients
that ignore the new fields keep working.
3. **Frontend**: `Upload.tsx` panel replaces the toast on 409. Users
who don't read the panel still see the toast and the 409 message
in the streaming view.
4. **No data migration**: nothing to migrate. 0013 is structural only.
@@ -0,0 +1,554 @@
# Cyclone Auth (admin / user / viewer) — Design
**Date:** 2026-06-22
**Status:** Draft (pending user review)
**Scope:** Adds username/password authentication and three predefined roles (admin, user, viewer) to the existing Cyclone FastAPI backend and React frontend. Browser-based login form, server-side SQLite sessions, HttpOnly cookie, role-gated endpoints. Single-machine deployment; no external IdP.
---
## 1. Overview
Cyclone currently has no authentication. The README states the system is "local-only on purpose: binds to `127.0.0.1`, no auth, no internet exposure. Built for one operator, one machine." That was true for the original single-operator design, but the system is now being prepared for production deployment where multiple humans will share the box (a clearinghouse operator, billing staff, and read-only auditors).
This sub-project adds the minimum viable role-based access control:
- A `/login` page that posts username + password to the backend.
- Server-side sessions in SQLite, keyed by an HttpOnly cookie.
- Three predefined roles — `admin`, `user`, `viewer` — with a static permission matrix.
- An admin-only user-management surface so the first admin can create other accounts.
- All existing endpoints get a `current_user` dependency; write-affording endpoints get a `require_role` gate.
After this, an operator can:
1. Start the stack with `CYCLONE_ADMIN_USERNAME=admin CYCLONE_ADMIN_PASSWORD=...` (or use a CLI command) so the first admin account is created on first boot.
2. Open `http://localhost:8081/`, get redirected to `/login`, sign in.
3. Browse the Dashboard, Claims, Remittances, etc. as a `user` or `viewer`.
4. As `admin`, visit a new Users page to create accounts, change roles, disable users, and reset passwords.
## 2. Goals
1. **Authenticate every API request.** Every existing endpoint under `/api/*` returns 401 unless a valid session cookie is present. The only unauthenticated paths are `/api/healthz` (declared before any auth dependency in `cyclone.api`) and `POST /api/auth/login` itself.
2. **Three predefined roles with a static permission matrix.** `admin` = everything including user management. `user` = read + write on claims/remits/batches/reconciliation + uploads. `viewer` = read-only — no uploads, no state changes.
3. **Server-side sessions in SQLite.** New `users` and `sessions` tables. 24-hour sliding expiry. HttpOnly cookie named `cyclone_session`.
4. **Bootstrap the first admin.** If `users` table is empty on startup, read `CYCLONE_ADMIN_USERNAME` / `CYCLONE_ADMIN_PASSWORD` env vars and create the first admin. If those env vars are also missing, refuse to start with a clear error message.
5. **Frontend `/login` route + auth context.** `AuthProvider` loads `/api/auth/me` on mount, redirects to `/login` on 401, restores the user on reload. Sidebar shows the real current user instead of the hardcoded "Jordan K.".
6. **Disable write-affording UI for `viewer`.** Upload, parse, resubmit, acknowledge, reconcile buttons render disabled with a tooltip when the role lacks permission. Server still gates as the source of truth — disabling the UI is a UX nicety.
7. **Audit log includes the acting user.** The existing `audit_log` table (SP11) gets a `user_id` column on a new migration; entries record the user that triggered each event.
8. **CLI command for user management.** `python -m cyclone users create <username> --role admin --password ...` works as an alternative to the admin UI for ops.
## 3. Non-goals (this sub-project)
- **LDAP / SAML / OIDC.** No external identity providers. Auth is local-only — credentials live in the SQLite `users` table.
- **Password reset emails / "forgot password" flow.** Admins reset passwords via the admin UI or CLI. There is no self-service flow.
- **Multi-factor authentication (MFA / TOTP).** Username + password is the only factor.
- **Per-resource ACLs / per-claim visibility.** The role applies globally. There is no concept of "this user can only see claims for provider X".
- **Account lockout after N failed attempts.** We rate-limit per username (5 fails per 5 min) but never permanently lock the account.
- **Cross-device session sync / "see my active sessions" UI.** Sessions are opaque cookie values; the admin UI shows the user list, not their sessions.
- **Branding / SSO.** Out of scope.
## 4. Stack
**Backend additions:**
- New module `cyclone.auth` (`users`, `sessions`, `permissions`, `routes`, `admin`, `deps`, `rate_limit`).
- `passlib[bcrypt]` for password hashing (industry standard, slow on purpose).
- `secrets.token_urlsafe(32)` for session IDs (256 bits of entropy).
- SQLAlchemy ORM (already in use) — new `User` and `Session` models on the same `cyclone.db.Base`.
- Migration `0015_users_and_sessions.py` creates both tables.
- FastAPI dependency injection for `get_current_user` and `require_role`.
**Frontend additions:**
- New `src/auth/` module: `AuthProvider` context, `useAuth` hook, `RoleGate` component, fetch wrapper that handles 401.
- New `/login` page (`src/pages/Login.tsx`).
- No new build tools; the existing Vite + React + react-query stack stays.
**Infrastructure:**
- nginx `proxy_cookie_path /api/ /;` so the session cookie path survives the frontend → backend reverse proxy.
- `docker-compose.yml` adds `CYCLONE_ADMIN_USERNAME` and `CYCLONE_ADMIN_PASSWORD` env vars to the backend service.
- Backend image installs `passlib[bcrypt]`.
## 5. Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌──────────────────────────────────────────┐ ┌──────────────────────┐ │
│ │ React SPA │ │ Cookie jar │ │
│ │ ┌────────────────────────────────────┐ │ │ cyclone_session=<id> │ │
│ │ │ <AuthProvider> │ │ │ HttpOnly, Lax, │ │
│ │ │ on mount: GET /api/auth/me │──┼──┼──Path=/api │ │
│ │ │ on 401: hard nav to /login │ │ └──────────────────────┘ │
│ │ │ </AuthProvider> │ │ │
│ │ │ ┌─────────────────────────────┐ │ │ │
│ │ │ │ <RoleGate allow=...> │ │ │ │
│ │ │ │ disables write buttons │ │ │ │
│ │ │ │ for users without role │ │ │ │
│ │ │ └─────────────────────────────┘ │ │ │
│ │ └────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────┘ │
└────────────────────────────────┬─────────────────────────────────────────┘
│ HTTPS (or HTTP behind LAN)
┌─────────────────────────────────────────────────────────────────────────┐
│ nginx (frontend container) │
│ - serves SPA static bundle │
│ - location /api/* → proxy_pass http://cyclone-backend:8000 │
│ - proxy_cookie_path /api/ /; (rewrites cookie path) │
└────────────────────────────────┬─────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ FastAPI (backend container) │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ cyclone.auth.routes POST /api/auth/login, logout │ │
│ │ GET /api/auth/me │ │
│ │ cyclone.auth.admin CRUD /api/admin/users │ │
│ │ cyclone.api (existing) every endpoint gains │ │
│ │ Depends(get_current_user) │ │
│ │ sensitive endpoints gain │ │
│ │ Depends(require_role("admin")) │ │
│ ├──────────────────────────────────────────────────────────────────┤ │
│ │ cyclone.auth.deps get_current_user, require_role │ │
│ │ cyclone.auth.sessions create/validate/expire │ │
│ │ cyclone.auth.users create/get/update/disable (bcrypt) │ │
│ │ cyclone.auth.permissions Role enum + matrix │ │
│ │ cyclone.auth.rate_limit in-memory 5-fail-per-5-min per username│ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ cyclone.db (SQLAlchemy) │ │
│ │ users(id, username UNIQUE, password_hash, role, │ │
│ │ created_at, disabled_at) │ │
│ │ sessions(id PK, user_id FK, expires_at, created_at) │ │
│ │ audit_log (existing — gets user_id column via 0016) │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
**Data flow on login:**
1. User opens `/login` (the SPA redirects there on first visit because `/api/auth/me` returned 401).
2. User submits `{username, password}``POST /api/auth/login`.
3. Backend rate-limit check: if username has ≥5 failed logins in the last 5 min, return 429 with `Retry-After`.
4. Backend looks up user by username. If not found OR `bcrypt.verify(password, password_hash)` fails, increment the rate-limit counter and return 401 `{error: "invalid_credentials"}`. The error message is generic so it doesn't leak whether the username exists.
5. If user.disabled_at is not None, return 403 `{error: "account_disabled"}`.
6. Create a `sessions` row: `id = secrets.token_urlsafe(32)`, `user_id`, `expires_at = now() + 24h`.
7. Set `Set-Cookie: cyclone_session=<id>; HttpOnly; SameSite=Lax; Path=/api; Max-Age=86400` (also `Secure` when behind HTTPS — detected via `request.url.scheme` or a `BEHIND_HTTPS=1` env var).
8. Return `200 {id, username, role, createdAt}`.
**Data flow on a normal request:**
1. Browser sends request with `Cookie: cyclone_session=<id>`.
2. FastAPI middleware / dependency reads the cookie, looks up `sessions` row by `id`.
3. If missing, expired (`expires_at < now`), or user is disabled → raise `HTTPException(401, "session_expired")`.
4. Otherwise, attach `User` to `request.state.user`. The `get_current_user` dependency returns it.
5. For endpoints with `Depends(require_role("admin"))`, check `user.role == "admin"`; else raise `HTTPException(403, "forbidden")`.
**Data flow on logout:**
1. SPA sends `POST /api/auth/logout`.
2. Backend deletes the `sessions` row matching the cookie's id.
3. Backend sets `Set-Cookie: cyclone_session=; Max-Age=0` to clear the cookie.
4. SPA's `AuthProvider` clears its state and navigates to `/login`.
**Sliding expiry:**
Every successful authenticated request refreshes `sessions.expires_at = now() + 24h` (cheap UPDATE) **and** re-emits the `Set-Cookie` header with a fresh `Max-Age=86400`. Without re-emitting the cookie, the browser would log the user out after 24h regardless of activity (because the original cookie's Max-Age expires). With this loop, an active user stays logged in indefinitely; an inactive user is logged out 24h after their last request.
## 6. Backend changes
### 6.1 New module `backend/src/cyclone/auth/`
```
auth/
├── __init__.py # re-exports the public API
├── users.py # User model, CRUD, bcrypt hashing
├── sessions.py # Session model, create/validate/expire
├── permissions.py # Role enum, permission matrix
├── deps.py # get_current_user, require_role
├── routes.py # /api/auth/login, logout, me
├── admin.py # /api/admin/users/* (admin-only)
└── rate_limit.py # per-username failed-login counter
```
### 6.2 Data model
New SQLAlchemy models in `cyclone.db`:
```python
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(16)) # "admin" | "user" | "viewer"
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Session(Base):
__tablename__ = "sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True) # token_urlsafe(32)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
```
Migration `0015_users_and_sessions.py` creates both tables and adds indexes on `users.username`, `sessions.expires_at`, `sessions.user_id`.
Migration `0016_audit_log_user_id.py` adds `user_id INT NULL` to the existing `audit_log` table (SP11's hash-chained audit log).
### 6.3 Permissions matrix
`cyclone/auth/permissions.py`:
```python
from enum import Enum
class Role(str, Enum):
ADMIN = "admin"
USER = "user"
VIEWER = "viewer"
# Endpoint path prefix → set of roles allowed.
PERMISSIONS: dict[str, set[Role]] = {
# Public paths (no auth required). Empty set = anyone, including unauthenticated.
"GET /api/healthz": set(),
"POST /api/auth/login": set(),
# Auth surface (authenticated, all roles).
"POST /api/auth/logout": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/auth/me": {Role.ADMIN, Role.USER, Role.VIEWER},
# Admin-only user management.
"GET /api/admin/users": {Role.ADMIN},
"POST /api/admin/users": {Role.ADMIN},
"PATCH /api/admin/users": {Role.ADMIN},
"DELETE /api/admin/users": {Role.ADMIN},
# Read endpoints (everyone authenticated can read).
"GET /api/claims": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/remittances": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/providers": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/batches": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/dashboard/summary": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/activity": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/inbox/lanes": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/reconcile": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/audit-log": {Role.ADMIN}, # SP11 — admin only
# Write endpoints (admin + user, no viewer).
"POST /api/parse-837": {Role.ADMIN, Role.USER},
"POST /api/parse-835": {Role.ADMIN, Role.USER},
"POST /api/inbox": {Role.ADMIN, Role.USER},
"POST /api/reconcile": {Role.ADMIN, Role.USER},
"POST /api/resubmit": {Role.ADMIN, Role.USER},
"POST /api/acks": {Role.ADMIN, Role.USER},
# ...every other POST/PATCH/DELETE goes here too.
# CSV export — read-only, so all roles.
"GET /api/export.csv": {Role.ADMIN, Role.USER, Role.VIEWER},
}
```
The `require_role` dependency reads `(method, path)` from the request and looks up the allowed roles. Endpoints not in the matrix default to **deny** — fail-closed.
### 6.4 Dependencies
```python
# deps.py
from fastapi import Depends, HTTPException, Request, status
async def get_current_user(request: Request) -> User:
sid = request.cookies.get("cyclone_session")
if not sid:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session_expired")
session = await sessions.get_valid(sid)
if not session:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session_expired")
user = await users.get(session.user_id)
if not user or user.disabled_at is not None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "account_disabled")
# Sliding expiry refresh.
await sessions.touch(sid)
request.state.user = user
return user
def require_role(*allowed: Role):
async def _dep(request: Request, user: User = Depends(get_current_user)) -> User:
method = request.method
path = request.url.path
key = f"{method} {path}"
# Longest-prefix match: e.g. /api/claims/CLM-1 matches "GET /api/claims".
allowed_roles = _lookup_permissions(key)
if user.role not in allowed_roles:
raise HTTPException(status.HTTP_403_FORBIDDEN, "forbidden")
return user
return _dep
```
### 6.5 Endpoints
**Auth (`cyclone/auth/routes.py`):**
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| POST | `/api/auth/login` | public | `{username, password}` | `200 {id, username, role, createdAt}` + Set-Cookie |
| POST | `/api/auth/logout` | session | — | `204` + cookie cleared |
| GET | `/api/auth/me` | session | — | `200 {id, username, role, createdAt}` |
**Admin (`cyclone/auth/admin.py`):**
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| GET | `/api/admin/users` | admin | — | `200 [{id, username, role, createdAt, disabledAt}]` |
| POST | `/api/admin/users` | admin | `{username, password, role}` | `201 {id, username, role, createdAt}` |
| PATCH | `/api/admin/users/{id}` | admin | `{role?, password?, disabled?}` | `200 {id, username, role, createdAt, disabledAt}` |
| DELETE | `/api/admin/users/{id}` | admin | — | `204` (only if user has no authored data; otherwise 409) |
**Error shapes:**
```json
// 401
{ "error": "session_expired", "detail": "Session is missing or expired." }
// 403
{ "error": "forbidden", "detail": "Your role lacks permission for this action." }
// 429
{ "error": "rate_limited", "detail": "Too many login attempts. Try again in N seconds." }
// Login 401 (generic — never leak username existence)
{ "error": "invalid_credentials", "detail": "Username or password is incorrect." }
```
### 6.6 Rate limit
In-memory dict in `rate_limit.py`:
```python
_FAILS: dict[str, list[float]] = {} # username → [timestamp, ...] of recent fails
WINDOW_SECONDS = 300
MAX_FAILS = 5
def check(username: str) -> int:
"""Return Retry-After seconds, or 0 if allowed."""
now = time.monotonic()
fails = [t for t in _FAILS.get(username, []) if now - t < WINDOW_SECONDS]
if len(fails) >= MAX_FAILS:
return int(WINDOW_SECONDS - (now - fails[0]))
return 0
def record_failure(username: str) -> None:
_FAILS.setdefault(username, []).append(time.monotonic())
def reset(username: str) -> None:
_FAILS.pop(username, None)
```
Per-process. Resets on backend restart. Acceptable for v1.
### 6.7 Bootstrap (first admin)
In `cyclone/__main__.py`, before `uvicorn.run(...)`:
```python
async def bootstrap_admin():
async with SessionLocal() as db:
if await db.scalar(select(func.count()).select_from(User)) > 0:
return
username = os.environ.get("CYCLONE_ADMIN_USERNAME")
password = os.environ.get("CYCLONE_ADMIN_PASSWORD")
if not username or not password:
raise RuntimeError(
"Cyclone has no users yet. Set CYCLONE_ADMIN_USERNAME and "
"CYCLONE_ADMIN_PASSWORD env vars, or run "
"`python -m cyclone users create <username> --role admin` first."
)
if len(password) < 12:
raise RuntimeError("CYCLONE_ADMIN_PASSWORD must be at least 12 characters.")
await users.create(db, username=username, password=password, role=Role.ADMIN)
print(f"[cyclone] bootstrap admin user '{username}' created")
```
### 6.8 CLI command
`python -m cyclone users ...`:
```
python -m cyclone users create <username> --role {admin|user|viewer} [--password <pw>]
python -m cyclone users list
python -m cyclone users disable <username>
python -m cyclone users reset-password <username> [--password <pw>]
python -m cyclone users set-role <username> --role {admin|user|viewer}
```
If `--password` is omitted, the CLI prompts interactively (no echo). On Windows / non-tty, refuse and require `--password` from env to avoid accidental empty-password accounts.
## 7. Frontend changes
### 7.1 New module `src/auth/`
```
auth/
├── AuthProvider.tsx # React context + reducer
├── AuthProvider.test.tsx
├── useAuth.ts # hook: {user, login, logout, status}
├── api.ts # fetch wrapper that handles 401
├── RoleGate.tsx # disables children when role not allowed
└── RoleGate.test.tsx
```
### 7.2 AuthProvider
```typescript
// AuthProvider.tsx (sketch)
type Status = "loading" | "authenticated" | "unauthenticated";
interface AuthState {
status: Status;
user: User | null;
}
const AuthContext = createContext<AuthState & { login, logout }>(...);
export function AuthProvider({ children }) {
const [state, setState] = useState<AuthState>({ status: "loading", user: null });
useEffect(() => {
api.getAuthMe()
.then(user => setState({ status: "authenticated", user }))
.catch(err => {
if (err.status === 401) setState({ status: "unauthenticated", user: null });
else setState({ status: "unauthenticated", user: null }); // network errors also treat as logged out
});
}, []);
// Expose login() that POSTs /api/auth/login + sets state on success.
// Expose logout() that POSTs /api/auth/logout + clears state + nav to /login.
}
```
### 7.3 Login page
`src/pages/Login.tsx`:
- Centered card on the existing dark background, ~400px wide.
- Username + password fields, "Sign in" button, error message slot.
- On submit: POST `/api/auth/login`, on success → `useNavigate()/<prev>` or `/`.
- On `401 invalid_credentials` → "Username or password is incorrect."
- On `403 account_disabled` → "Account is disabled. Contact your administrator."
- On `429 rate_limited` → "Too many attempts. Try again in N seconds." (N from `Retry-After`).
- The page renders *without* the sidebar — it lives outside the protected `<Layout>`.
### 7.4 API wrapper
`src/auth/api.ts` re-exports the existing `lib/api.ts` but wraps `fetch` so any 401 response:
1. Calls `POST /api/auth/logout` (best-effort, ignore failure).
2. Clears the auth context.
3. `window.location.href = "/login?next=" + encodeURIComponent(currentPath)`.
Use a hard navigation (not `<Navigate>`) so the SPA's in-memory state is wiped.
### 7.5 RoleGate
```tsx
// RoleGate.tsx
interface Props {
allow: Role[];
children: ReactNode;
fallback?: ReactNode; // optional explicit "no permission" UI
}
export function RoleGate({ allow, children, fallback }: Props) {
const { user } = useAuth();
if (!user) return null;
if (allow.includes(user.role)) return <>{children}</>;
if (fallback) return <>{fallback}</>;
return (
<Tooltip content={`Your role (${user.role}) cannot perform this action.`}>
<span className="pointer-events-none opacity-50">{children}</span>
</Tooltip>
);
}
```
Applied at the call sites — e.g. wrap the Upload dropzone, the Parse button, the Resubmit button, the Acknowledge action, the Reconcile "match" button.
### 7.6 Sidebar
`src/components/Sidebar.tsx` — replace the hardcoded "Jordan K. / Administrator" block with `<CurrentUser />` which reads `useAuth()`. Shows the user's actual username + role.
### 7.7 Routes
`src/main.tsx`:
- `QueryClientProvider`
- `AuthProvider`
- `<BrowserRouter>` with two route groups:
- **Public:** `/login` (no `<Layout>` wrap).
- **Protected:** everything else, wrapped in a `<RequireAuth>` guard that waits for `AuthProvider.status === "loading"` to resolve, then renders `<Outlet />` or `<Navigate to="/login" />`.
### 7.8 react-query keys
`useAuth()` itself is not stored in react-query (it's a long-lived auth state, not a query). Existing query keys (`['claims', ...]`, etc.) are unchanged. On logout, the app does a hard reload to `/login` which wipes all react-query state.
## 8. Infrastructure
### 8.1 nginx (`frontend/nginx.conf`)
The existing nginx config already proxies `/api/*` to the backend. With cookie-based auth, the only requirement is that the cookie's `Path` matches a prefix of the request URL the browser sends. We set `Path=/api` on the cookie (matching the proxied path), and the browser sends it automatically on every `/api/*` request to the frontend nginx — no `proxy_cookie_path` rewrite needed.
We do add `proxy_set_header X-Forwarded-Proto $scheme;` so the backend can detect when it's behind HTTPS and emit the `Secure` cookie flag.
### 8.2 docker-compose.yml
```yaml
services:
backend:
environment:
CYCLONE_ADMIN_USERNAME: ${CYCLONE_ADMIN_USERNAME:?set me}
CYCLONE_ADMIN_PASSWORD: ${CYCLONE_ADMIN_PASSWORD:?set me}
# CYCLONE_BEHIND_HTTPS=1 # uncomment if behind an HTTPS reverse proxy
```
A `.env.example` documents the required vars.
### 8.3 Cookie `Secure` flag
Detected at request time: if `request.url.scheme == "https"` OR `os.environ.get("CYCLONE_BEHIND_HTTPS") == "1"`, the cookie gets `Secure`. This lets the same binary work in dev (`http://localhost`) and prod (HTTPS in front of nginx).
## 9. Testing
### 9.1 Backend (`backend/tests/`)
| File | Coverage |
|---|---|
| `test_auth_users.py` | bcrypt hash/verify round-trip; create/get/disable; password never returned in any response shape; username uniqueness; role must be one of the enum |
| `test_auth_sessions.py` | create/validate/expire; expired session is rejected; cookie attrs (HttpOnly, Path, Max-Age) on the login response |
| `test_auth_routes.py` | login success path returns 200 + cookie; login with bad password returns 401 with `invalid_credentials`; login with disabled user returns 403; logout deletes the session row + clears cookie; /me with valid cookie returns user; /me with no cookie returns 401 |
| `test_auth_permissions.py` | for each role × each endpoint, assert the right HTTP code (200/401/403); fail-closed: endpoints not in the matrix return 403 |
| `test_auth_admin.py` | admin can GET/POST/PATCH/DELETE users; non-admin gets 403 on every /api/admin/* path; admin cannot delete themselves (409); admin cannot demote themselves below admin (409) |
| `test_auth_bootstrap.py` | empty users + env vars → admin created; empty users + missing env vars → backend refuses to start; non-empty users → env vars ignored |
| `test_auth_login_rate_limit.py` | 5 failed logins OK, 6th returns 429 with Retry-After; counter resets after window; successful login resets the counter |
| `test_audit_log_user_id.py` | existing audit-log entries get NULL user_id (back-compat); new entries after auth lands record the acting user's id |
| `test_existing_endpoints_require_auth.py` | spot-check 10 existing endpoints (claims GET, parse-837 POST, etc.) all return 401 without a cookie |
### 9.2 Frontend (`src/**/__tests__/`)
| File | Coverage |
|---|---|
| `src/auth/AuthProvider.test.tsx` | /me on mount populates user; 401 from /me leaves user null; login() POSTs and updates state; logout() POSTs and clears state |
| `src/auth/RoleGate.test.tsx` | renders children for allowed role; renders disabled-with-tooltip for disallowed role; renders fallback when provided |
| `src/pages/Login.test.tsx` | form submits with username/password; error message on 401; redirect to `next` query param on success; rate-limit message on 429 |
| `src/lib/api.test.ts` (extended) | fetch wrapper hard-navigates to `/login` on 401; passes through on other statuses |
### 9.3 End-to-end
`/tmp/verify_auth.py` (Playwright) — login with seeded admin, verify dashboard renders, verify viewer cannot see Upload dropzone enabled, verify viewer clicking Upload sees a disabled state with tooltip.
## 10. Risk + open questions
- **Cookie path rewriting is fragile.** If a future deployment puts the API at a different prefix than `/api/`, the `proxy_cookie_path` will need to change. Documented in the README.
- **The audit-log migration (0016) is technically out of scope** for "add auth" — but the user said "I want admin, user, viewer. or something like that" and not seeing *who* did *what* in the audit log is a half-measure. Include it.
- **The CLI command on Windows** will need to handle non-tty differently from Linux/macOS. Stubbed: require `--password` from env on non-tty platforms.
- **No logout-everywhere UI.** If an admin wants to invalidate all sessions for a user, they currently have to wait for sessions to expire or wipe the `sessions` table directly. Acceptable for v1; admin can `DELETE FROM sessions WHERE user_id = ?` from `sqlite3 /data/cyclone.db` if urgent.
## 11. Rollout
- All code lands in one PR (sub-project is small enough).
- Migrations 0015 + 0016 ship together; both are backwards-compatible (additive).
- The Docker image rebuilds pick up `passlib[bcrypt]` automatically.
- `README.md` gets a new "Auth" section explaining env vars, the bootstrap admin, the CLI command, and the role matrix.
- Auth applies everywhere once enabled — both the Docker deployment (`http://localhost:8081`) and the Vite dev server (`http://localhost:5173`, which proxies `/api/*` to the backend on port 8000). Operators who want to disable auth in dev can set `CYCLONE_AUTH_DISABLED=1` env var; the bootstrap function is a no-op when this is set, and the `get_current_user` dependency returns a synthetic admin user. This is purely a developer-experience escape hatch — production deployments leave it unset.
@@ -0,0 +1,816 @@
# Cyclone Ubuntu Docker Deployment — Design
**Date:** 2026-06-22
**Status:** Approved (pending user review of this doc)
**Depends on:** [2026-06-19-cyclone-production-readiness-design.md](2026-06-19-cyclone-production-readiness-design.md) (in-memory store + react-query wiring; local-only)
**Replaces:** The "no auth, no Docker, local-only" posture of the parent spec with a production-grade posture for real PHI on a single Ubuntu server.
---
## 1. Overview
Cyclone's first sub-project shipped a usable local-only system: parse 837/835 files, browse the data, edit and resubmit rejected claims, export a corrected 837 ZIP. Everything ran on `127.0.0.1` with no auth, in-memory storage, and a `python -m cyclone serve` invocation.
This sub-project graduates that to a production-grade deployment on a single Ubuntu Linux server:
- **Two-container Docker Compose** (backend + frontend) replacing the dev-server split
- **App-layer authentication** with username + password, argon2id hashing, session cookies, and three fixed roles (admin / operator / viewer)
- **SQLCipher encryption at rest** for the SQLite DB, with the key as a Docker secret
- **Daily encrypted backups** with the existing AES-256-GCM `BackupService`, scheduler autostart, 14-day retention
- **LAN-only bind** (single operator, single Ubuntu server); VPN handles outside access
- **Manual `docker compose pull` updates** with semver tags; one-env-var rollback
- **Operator runbook** + bootstrap scripts so the deploy is reproducible
Real PHI flows through this deployment. The SFTP wire stays stubbed — operators download the corrected 837 ZIP and hand it to the Gainwell MFT UI manually. That's intentional and documented as v1 scope; SFTP wire is v2.
After this ships, the operator can:
1. `git clone <repo> /opt/cyclone && cd /opt/cyclone`
2. `docker compose build`
3. `bash scripts/cyclone-init.sh` → generates `/etc/cyclone/secrets/{db,secret,admin_pw}.key`, prints the admin password once
4. `docker compose up -d`
5. Open `http://<lan-ip>:8080/login` → log in as `admin`, change password, create operators
6. Upload parsed 837s, edit claims, export ZIPs, hand to MFT UI
7. Trust daily encrypted backups + the operator's off-box cron copy for ≤24h loss
## 2. Goals
1. **Dockerize the existing stack** so it runs the same way in production as in development. One repo, one `docker compose up -d`.
2. **Add app-layer authentication** with username/password + 3-role RBAC, replacing the current "no auth at all" posture.
3. **Encrypt the SQLite DB at rest** via SQLCipher; key as a Docker secret (not an env var, not a keychain on macOS).
4. **Wire the existing encrypted backup system** to autostart on container boot with sane defaults (24h interval, 14-day retention).
5. **Add an operator runbook + bootstrap scripts** so the system is reproducible from `git clone` and supportable without tribal knowledge.
6. **Preserve all existing functionality** — the parser, editor, exporter, scheduler, audit log, backup/restore flow all keep working unchanged from the user's perspective.
## 3. Non-goals (this sub-project)
- **Real SFTP wire (paramiko)** — keep stub. Operators download ZIPs and use the MFT UI. Listed as v2.
- **Public TLS / public domain / Caddy reverse proxy** — LAN-only bind for v1; VPN handles outside access. Listed as v2.
- **Multi-host HA / DB replication / load balancing** — single Ubuntu server. Listed as v2.
- **Prometheus / Grafana / real alerting** — v1 uses the simple `curl healthz | mail` cron pattern. Listed as v2.
- **Off-box automated backup shipping** — operator's responsibility via host cron / rsync. Documented in runbook, not automated.
- **2FA / SSO / OAuth** — v2.
- **Self-service password reset** — v2; v1 admin resets via `cyclone admin reset-password` CLI.
- **Per-file encryption of prodfiles / sftp_staging volumes** — v2; v1 relies on volume-level filesystem permissions + the SQLCipher-encrypted DB + the host being physically secure.
- **LUKS full-disk encryption** — out of scope; assumed to be an operator-level concern (Ubuntu installer offers it).
- **Automatic update mechanism** (Watchtower etc.) — v1 is manual `docker compose pull`. Listed as v2.
- **New X12 transaction types or validation rules** — not this sub-project.
## 4. Stack
**Backend additions:**
- `argon2-cffi` (new dep) for password hashing
- `cyclone.auth` module: `User` / `Session` SQLAlchemy models, password hashing helpers, lockout logic
- `cyclone.api.auth` routes: `/api/auth/{login,logout,change-password,me}`
- `cyclone.api.users` routes: `/api/admin/users` (admin-only CRUD)
- `cyclone.cli` additions: `cyclone admin {create-user,reset-password,list-users}`
- Migration `0012_users_sessions.sql` for `users` + `sessions` tables
- Existing `BackupService` (SP17) and `audit_log` machinery reused unchanged
**Backend Dockerfile** (`backend/Dockerfile`):
- Multi-stage: builder stage installs `.[sqlcipher]` (sqlcipher is optional but recommended; the engine falls back to plain SQLite if the package isn't actually present, same graceful default as today), runtime stage copies installed site-packages + source
- Base: `python:3.11-slim-bookworm` (matches `requires-python = ">=3.11"`)
- Non-root user `cyclone` (uid 1000)
- `HEALTHCHECK CMD curl -fs http://localhost:8000/api/healthz || exit 1`
- Entrypoint: `python -m cyclone serve`
**Frontend additions:**
- `nginx.conf` for SPA routing + reverse proxy to backend
- `Dockerfile.frontend`: multi-stage builder (node:20-alpine runs `npm ci && npm run build`) + runtime (nginx:1.27-alpine serving `dist/`)
- `useCurrentUser()` hook (react-query)
- `Login.tsx` page
- `<AuthGate>` wrapper in `App.tsx`
- `<NavBar>` shows username + role badge + logout button
- `/admin/users` page (admin-only)
**Infrastructure:**
- `docker-compose.yml` at repo root
- `scripts/cyclone-init.sh` — generates `/etc/cyclone/secrets/*` with `openssl rand -hex 32`
- `scripts/post-deploy.sh` — sets up logrotate + healthcheck cron on host
- `scripts/smoke.sh` — bring-up + login + parse + export end-to-end test
- `RUNBOOK.md` — daily/weekly/quarterly/as-needed ops procedures
- `.dockerignore` files for both build contexts
## 5. Architecture
```
┌─────────────────────────────────────────────────────┐
│ Ubuntu Linux server (your machine) │
│ │
│ cyclone_network (bridge) │
│ ┌─────────────────────┐ ┌────────────────────┐ │
│ │ cyclone-backend │ │ cyclone-frontend │ │
│ │ FastAPI │◄───┤ nginx (SPA + proxy)│ │
│ │ :8000 (internal) │ │ :8080 (host) │ │
│ └──────┬──────────────┘ └─────────┬──────────┘ │
│ │ │ │
└─────────┼─────────────────────────────┼────────────┘
│ │
┌──────▼──────────────────┐ ┌──────▼──────────┐
│ Named volumes │ │ Bind mounts │
│ cyclone_db │ │ ./dist (built) │
│ cyclone_backups │ │ ./nginx.conf │
│ cyclone_prodfiles │ └─────────────────┘
│ cyclone_sftp_staging │
└─────────────────────────┘
/etc/cyclone/secrets/ (host, chmod 600, root:root)
├─ db.key → /run/secrets/cyclone_db_key
├─ secret.key → /run/secrets/cyclone_secret_key
└─ admin_pw → /run/secrets/cyclone_admin_password
```
**Why two containers:** nginx serves the React build faster than FastAPI would, decouples frontend rebuild cadence from backend release cadence, and is the standard ops pattern. nginx also reverse-proxies `/api/*` to the backend so the browser only talks to one origin (no CORS needed, no preflight requests for the `Cookie` header).
**Why LAN-only bind:** nginx listens on `0.0.0.0:8080` inside the host network namespace, but the operator is expected to bind to the LAN IP via firewall rules / not exposing on the WAN. VPN handles outside access. No public TLS needed for v1.
**Container-to-container networking:** the frontend container reaches the backend at `http://cyclone-backend:8000` over the compose-managed bridge network. The browser only ever sees `http://<lan-ip>:8080`.
**Healthcheck:** container-level `curl -fs http://localhost:8000/api/healthz` every 30s, 3 retries. Compose `restart: unless-stopped` on healthcheck failure.
**Reverse proxy in nginx:**
```nginx
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# API + auth: proxy to backend
location /api/ {
proxy_pass http://cyclone-backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # long enough for big parse streams
client_max_body_size 50m; # claims files can be large
}
# SPA: serve index.html for all non-/api routes
location / {
try_files $uri $uri/ /index.html;
}
}
```
## 6. Backend changes
### 6.1 New module `backend/src/cyclone/auth/__init__.py`
```python
@dataclass(frozen=True)
class Role:
VIEWER = "viewer"
OPERATOR = "operator"
ADMIN = "admin"
ROLE_RANK = {Role.VIEWER: 0, Role.OPERATOR: 1, Role.ADMIN: 2}
class User(Base):
__tablename__ = "users"
id: Mapped[str] # uuid4 hex
username: Mapped[str] # UNIQUE
password_hash: Mapped[str] # argon2id
role: Mapped[str] # viewer | operator | admin
created_at: Mapped[datetime]
last_login_at: Mapped[datetime | None]
failed_count: Mapped[int] = 0
locked_until: Mapped[datetime | None] = None
class Session(Base):
__tablename__ = "sessions"
id: Mapped[str] # 32-byte hex token
user_id: Mapped[str]
created_at: Mapped[datetime]
expires_at: Mapped[datetime]
ip: Mapped[str | None]
user_agent: Mapped[str | None]
```
Password hashing uses `argon2-cffi` with the OWASP-recommended parameters (`time_cost=2`, `memory_cost=19456`, `parallelism=1`, `hash_len=32`).
### 6.2 Auth routes in `backend/src/cyclone/api.py` (new router `auth_router`)
| Method | Path | Behavior | Auth |
|---|---|---|---|
| POST | `/api/auth/login` | `{username, password}` → verify argon2id, increment `failed_count` on miss (lock at 5/15min), create session, set `HttpOnly; Secure; SameSite=Lax` cookie | none |
| POST | `/api/auth/logout` | delete session row, clear cookie | any logged-in |
| GET | `/api/auth/me` | `{user: {username, role}}` or 401 | any logged-in |
| POST | `/api/auth/change-password` | `{old_password, new_password}` → verify, rehash | any logged-in |
| POST | `/api/admin/users` | `{username, password, role}` → create user | admin |
| GET | `/api/admin/users` | list users | admin |
| PATCH | `/api/admin/users/{id}` | `{role?, password?}` | admin |
| DELETE | `/api/admin/users/{id}` | soft-disable (set `disabled_at`) | admin |
### 6.3 The `require_role` dependency
Every existing protected route gets a `dependencies=[Depends(require_role(Role.OPERATOR))]` annotation:
```python
def require_role(min_role: str):
"""FastAPI dependency factory: read cookie, load user, enforce role."""
def dep(request: Request) -> User:
sid = request.cookies.get("cyclone_session")
if not sid:
raise HTTPException(401, "not authenticated")
with db.SessionLocal()() as s:
sess = s.get(Session, sid)
if sess is None or sess.expires_at < utcnow():
raise HTTPException(401, "session expired")
user = s.get(User, sess.user_id)
if user is None:
raise HTTPException(401, "user gone")
if user.locked_until and user.locked_until > utcnow():
raise HTTPException(423, "account locked")
if ROLE_RANK[user.role] < ROLE_RANK[min_role]:
raise HTTPException(403, f"requires {min_role}")
request.state.user = user
request.state.actor = user.username # existing audit-log contract
return user
return dep
```
**Role → action matrix:**
| Action | viewer | operator | admin |
|---|:-:|:-:|:-:|
| View claims, batches, acks | ✓ | ✓ | ✓ |
| `POST /api/parse-837`, `/api/parse-835` | — | ✓ | ✓ |
| Edit claim fields, resubmit rejected | — | ✓ | ✓ |
| `POST /api/batches/{id}/export-837` | — | ✓ | ✓ |
| `POST /api/clearhouse/submit` | — | ✓ | ✓ |
| `/api/admin/users` CRUD | — | — | ✓ |
| `/api/admin/backup/*` | — | — | ✓ |
| `/api/config/*` (clearhouse, payers) | — | — | ✓ |
| `/api/admin/audit-log` | — | — | ✓ |
| Backup scheduler on/off | — | — | ✓ |
**Login rate-limit:** the existing `cyclone.api.security.request_rejected` machinery already rate-limits by IP; we add per-username lockout on top:
- `failed_count >= 5` within 15min → `locked_until = now + 15min`
- Locked accounts return 423; the lock clears after the cooldown
### 6.4 Audit log wiring
The existing `audit_log.append(actor=..., ...)` calls already accept an actor string. The new auth dependency sets `request.state.actor = user.username`, and a small middleware reads it for every request. No audit-log schema change.
### 6.5 CLI additions
```bash
cyclone admin create-user --username X --role admin --password <from_stdin or --password-file>
cyclone admin reset-password --username X --new-password <from_stdin or --password-file>
cyclone admin list-users
```
Used by `scripts/cyclone-init.sh` for the bootstrap admin, and by the operator for password recovery.
### 6.6 Migration `0012_users_sessions.sql`
```sql
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'operator', 'viewer')),
created_at TIMESTAMP NOT NULL,
last_login_at TIMESTAMP,
failed_count INTEGER NOT NULL DEFAULT 0,
locked_until TIMESTAMP,
disabled_at TIMESTAMP
);
CREATE INDEX idx_users_username ON users(username);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
ip TEXT,
user_agent TEXT
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
```
### 6.7 Session timeout config
| Setting | Default | Env var |
|---|---|---|
| Session absolute lifetime | 30 days | `CYCLONE_SESSION_ABSOLUTE_DAYS` |
| Session sliding expiry | 8 hours | `CYCLONE_SESSION_SLIDING_HOURS` |
| Cookie name | `cyclone_session` | `CYCLONE_COOKIE_NAME` |
| Cookie `Secure` flag | true | always true in prod |
### 6.8 Backend Dockerfile
```dockerfile
# Builder stage
FROM python:3.11-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libsqlcipher-dev libffi-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY pyproject.toml .
COPY src/ ./src/
RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher]'
# Runtime stage
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
libsqlcipher-dev curl tini \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 1000 cyclone
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links /wheels cyclone
COPY src/ /app/src/
USER cyclone
ENV PYTHONUNBUFFERED=1
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fs http://localhost:8000/api/healthz || exit 1
ENTRYPOINT ["tini", "--"]
CMD ["python", "-m", "cyclone", "serve"]
```
`sqlcipher` is included but the engine falls back to plain SQLite if the package isn't actually installed — same graceful default as today, just biased toward enabling encryption in prod.
## 7. Frontend changes
### 7.1 New login page `src/pages/Login.tsx`
A simple form: username + password inputs, submit button, error message slot. On success, navigate to `/`. Lives outside the auth-gated shell, served from a route that doesn't require authentication.
```tsx
export default function Login() {
const login = useMutation({
mutationFn: ({ username, password }) => api.login(username, password),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me'] }),
});
// ...
}
```
### 7.2 `useCurrentUser` hook
```ts
export function useCurrentUser() {
return useQuery({
queryKey: ['me'],
queryFn: () => api.me(),
retry: false,
staleTime: 60_000,
});
}
```
### 7.3 AuthGate in `App.tsx`
```tsx
function AuthGate({ children }: { children: React.ReactNode }) {
const { data: user, isLoading } = useCurrentUser();
const location = useLocation();
if (isLoading) return <FullPageSkeleton />;
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
return <>{children}</>;
}
```
Wrap every route except `/login` in `<AuthGate>`. `/login` itself does *not* require auth.
### 7.4 Role-based UI gating
```ts
const ROLE_RANK = { viewer: 0, operator: 1, admin: 2 };
export function useCan(minRole: 'viewer' | 'operator' | 'admin'): boolean {
const { data: user } = useCurrentUser();
if (!user) return false;
return ROLE_RANK[user.role] >= ROLE_RANK[minRole];
}
```
Used in NavBar to hide "Admin" links from operators/viewers, and on the `/admin/users` page as a guard (`if (!useCan('admin')) return <Forbidden />`).
### 7.5 NavBar changes
- Username + role badge (admin/operator/viewer) shown right-aligned
- Logout button (calls `api.logout()`, clears react-query cache, navigates to `/login`)
- "Admin" dropdown appears only when `useCan('admin')`
### 7.6 `/admin/users` page (admin only)
Table of users (username, role, last login, status). Actions:
- **Create user** (modal with username + password + role)
- **Edit role** (inline select)
- **Reset password** (modal)
- **Disable / enable** (toggle; sets `disabled_at`)
### 7.7 api.ts additions
```ts
api.login(username: string, password: string): Promise<{ user: User }>
api.logout(): Promise<void>
api.me(): Promise<{ user: User }> // throws 401 if not logged in
api.changePassword(oldPw: string, newPw: string): Promise<void>
api.listUsers(): Promise<User[]>
api.createUser(...): Promise<User>
api.updateUser(id: string, ...): Promise<User>
api.deleteUser(id: string): Promise<void>
```
All `api.*` calls send `credentials: 'include'` so the session cookie flows.
### 7.8 Frontend Dockerfile + nginx.conf
`frontend/Dockerfile`:
```dockerfile
# Build stage
FROM node:20-alpine AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /build/dist /usr/share/nginx/html
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8080/ >/dev/null || exit 1
```
The nginx config shown in §5 lives at `frontend/nginx.conf`.
## 8. Data, secrets, backups
### 8.1 Named volumes
| Volume | Mount path in container | Contents |
|---|---|---|
| `cyclone_db` | `/var/lib/cyclone/db/cyclone.db` | SQLCipher-encrypted SQLite |
| `cyclone_backups` | `/var/lib/cyclone/backups/` | AES-256-GCM `.bin` + `.meta.json` |
| `cyclone_prodfiles` | `/var/lib/cyclone/prodfiles/` | Uploaded 837 `.txt`, downloaded 835 |
| `cyclone_sftp_staging` | `/var/lib/cyclone/sftp_staging/` | SFTP stub's outbound/inbound |
These are managed by Docker (`docker volume create` / compose `volumes:`); they live under `/var/lib/docker/volumes/` on the host.
### 8.2 Docker secrets (host-managed)
| File | Mounted as | Purpose |
|---|---|---|
| `/etc/cyclone/secrets/db.key` | `/run/secrets/cyclone_db_key` | SQLCipher `PRAGMA key` — 64 hex chars |
| `/etc/cyclone/secrets/secret.key` | `/run/secrets/cyclone_secret_key` | Cookie signing key — 64 hex chars |
| `/etc/cyclone/secrets/admin_pw` | `/run/secrets/cyclone_admin_password` | One-time bootstrap admin password |
`scripts/cyclone-init.sh` generates all three with `openssl rand -hex 32` (db.key + secret.key) and a memorable-but-random admin password, sets permissions to `chmod 600 root:root`, and prints the admin password once.
### 8.3 Backup strategy (≤24h loss is acceptable)
- The existing `BackupService` (SP17) is already implemented and tested: takes online snapshot via SQLite `.backup()` API, encrypts with AES-256-GCM, writes `.bin` + `.meta.json` in `cyclone_backups` volume
- `CYCLONE_BACKUP_AUTOSTART=1` in compose starts the in-container backup scheduler on container start
- `CYCLONE_BACKUP_INTERVAL_HOURS=24` (default)
- 14-day retention (default; configurable via `CYCLONE_BACKUP_RETENTION_DAYS`)
- Restore via existing `/api/admin/backup/{id}/restore/{initiate,confirm}` (admin-only after auth is wired)
- Existing audit-log call `actor="backup-scheduler"` becomes `actor="backup-scheduler"` (unchanged — system actor, not a user)
**Off-box backup copy** (operator's responsibility, documented in runbook):
The operator sets up a host cron / systemd timer that rsyncs `/var/lib/docker/volumes/cyclone_backups/_data/` to an external drive or NAS nightly. The `.bin` files are already encrypted so the destination doesn't need its own encryption. Documented in `RUNBOOK.md` as a required post-deploy step.
### 8.4 Data loss scenarios
| Scenario | Outcome |
|---|---|
| Container crashes | Docker restarts; SQLite WAL recovers; no loss |
| Disk corruption | Restore from latest local backup (≤24h old) |
| Disk total loss | Restore from off-box backup copy (depends on operator's cron cadence) |
| DB key compromise | Rotate via `POST /api/admin/db/rotate-key`; old backups re-encrypted on next cycle |
| Operator forgets admin password | Reset via `cyclone admin reset-password --username admin --new-password X` |
### 8.5 Encryption-at-rest coverage
| Asset | Encrypted at rest (v1) | Notes |
|---|---|---|
| Live SQLite DB | ✓ SQLCipher | AES-256, key in Docker secret |
| Backup `.bin` files | ✓ AES-256-GCM | Key from backup passphrase |
| Prodfiles (uploaded 837s) | — plain on disk | v2: per-file encryption or move to DB |
| sftp_staging | — plain on disk | v2: per-file encryption |
| Log files | — plain on disk | rotated, contained |
v1 assumes the host is physically secure (single-operator server, locked room) and that LUKS is operator-level.
## 9. Logging, monitoring, updates, ops
### 9.1 Logging
- JSON to stdout (already configured via `logging_config.py`) — Docker collects via `docker logs`
- Bind-mount `/var/log/cyclone/` from the host so logrotate can manage retention (configured in `scripts/post-deploy.sh`)
- Log levels: INFO in prod, DEBUG opt-in via `CYCLONE_LOG_LEVEL=DEBUG`
- Every login event (success + failure) logged with username, ip, user-agent, role
- Every state-changing API call carries `actor=<user_id>` (existing audit-log machinery; we wire the auth user into `request.state.actor`)
- New audit events: `auth.login`, `auth.logout`, `auth.login_failed`, `auth.password_changed`, `auth.user_created`, `auth.user_updated`, `auth.user_disabled`
### 9.2 Healthcheck
- Container-level: `curl -fs http://localhost:8000/api/healthz` every 30s, 3 retries (configured in Dockerfile)
- Compose `restart: unless-stopped` on healthcheck failure (max 3 restarts then backoff)
- `GET /api/healthz` returns `{db_ok: bool, scheduler_running: bool, last_backup_at: timestamp}` (existing endpoint, unchanged)
### 9.3 Monitoring (v1 minimal)
- No Prometheus/Grafana in v1
- Host-level cron: `curl -fs http://localhost:8080/api/healthz >/dev/null || echo "Cyclone down at $(date)" | mail -s "cyclone DOWN" you@example.com`
- Set up by `scripts/post-deploy.sh` during initial bootstrap
### 9.4 Updates
- Images tagged semver from `package.json` / `pyproject.toml`: `cyclone-backend:0.1.0`, `cyclone-frontend:0.1.0`
- Two tag aliases per image:
- `latest` — set automatically by `docker compose build` (most recent build)
- `stable` — manually promoted by the operator once a release has run in prod for ≥1 week without issues, via `docker tag cyclone-backend:0.1.0 cyclone-backend:stable && docker tag cyclone-frontend:0.1.0 cyclone-frontend:stable`. Compose defaults to `:stable` so a new build doesn't get auto-rolled-out.
- Update procedure (to pick up a new `:stable`):
```bash
cd /opt/cyclone
docker compose pull # pulls newer :stable tags
docker compose up -d # recreates containers, preserves volumes + secrets
```
- DB migrations run automatically on backend start; migrations are forward-only with documented downgrade procedures (existing pattern)
- Rollback: `TAG=0.0.9 docker compose up -d` (one env var override; compose reads `${TAG:-stable}` for the image tag)
- Previous image stays in Docker's local cache for one cycle so rollback is offline
### 9.5 Initial bootstrap
```bash
git clone <repo> /opt/cyclone && cd /opt/cyclone
docker compose build # or: docker compose pull
bash scripts/cyclone-init.sh # generates /etc/cyclone/secrets/*, prints admin password
docker compose up -d # starts both containers
# wait ~10s for migrations
docker compose exec backend cyclone admin create-user \
--username admin --role admin --password-file /run/secrets/cyclone_admin_password
# open browser to http://<lan-ip>:8080/login
# log in as admin, change password, create operators
bash scripts/post-deploy.sh # logrotate + healthcheck cron
```
### 9.6 Daily ops (operator runbook)
- **Daily:** check `GET /api/healthz` (or trust the healthcheck email)
- **Weekly:** review `/admin/audit-log` page for unusual activity
- **Quarterly:** rotate DB key via `POST /api/admin/db/rotate-key`
- **As needed:** create operators via `/admin/users`; restore from backup via the restore UI
- **Annual:** rotate cookie signing key (re-login all users)
Full procedures live in `RUNBOOK.md` shipped in the repo.
## 10. docker-compose.yml
```yaml
name: cyclone
services:
backend:
image: cyclone-backend:${TAG:-stable}
build: ./backend
restart: unless-stopped
environment:
CYCLONE_DB_URL: "sqlite:////var/lib/cyclone/db/cyclone.db"
CYCLONE_BACKUP_AUTOSTART: "1"
CYCLONE_BACKUP_INTERVAL_HOURS: "24"
CYCLONE_BACKUP_RETENTION_DAYS: "14"
CYCLONE_LOG_LEVEL: "INFO"
CYCLONE_LOG_FILE: "/var/log/cyclone/cyclone.log"
CYCLONE_LOG_JSON: "1"
CYCLONE_SESSION_ABSOLUTE_DAYS: "30"
CYCLONE_SESSION_SLIDING_HOURS: "8"
CYCLONE_COOKIE_SECURE: "1"
secrets:
- cyclone_db_key
- cyclone_secret_key
volumes:
- cyclone_db:/var/lib/cyclone/db
- cyclone_backups:/var/lib/cyclone/backups
- cyclone_prodfiles:/var/lib/cyclone/prodfiles
- cyclone_sftp_staging:/var/lib/cyclone/sftp_staging
- cyclone_logs:/var/log/cyclone
healthcheck:
test: ["CMD", "curl", "-fs", "http://localhost:8000/api/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks: [cyclone_network]
frontend:
image: cyclone-frontend:${TAG:-stable}
build: ./frontend
restart: unless-stopped
ports:
- "8080:8080"
depends_on:
backend:
condition: service_healthy
networks: [cyclone_network]
secrets:
cyclone_db_key:
file: /etc/cyclone/secrets/db.key
cyclone_secret_key:
file: /etc/cyclone/secrets/secret.key
cyclone_admin_password:
file: /etc/cyclone/secrets/admin_pw
volumes:
cyclone_db:
cyclone_backups:
cyclone_prodfiles:
cyclone_sftp_staging:
cyclone_logs:
networks:
cyclone_network:
driver: bridge
```
The `8080:8080` port is the only one published to the host. The operator is expected to bind to the LAN IP at the firewall level (UFW rules or similar).
## 11. Error handling
- **Login failures:** increment `failed_count`; lock at 5 fails / 15min; log `auth.login_failed`; 423 on locked accounts.
- **Session expiry:** 401; frontend redirects to `/login` with `state.from` preserved.
- **Missing role:** 403 with explicit message; UI shows a "Forbidden" page instead of trying to render.
- **Backend crash:** Docker healthcheck restarts the container; SQLite WAL recovers in-flight writes.
- **DB key missing:** container starts but `db.is_encryption_enabled()` returns False; `BackupService` refuses to run (existing behavior); compose logs a warning.
- **Migration failure on startup:** container exits with non-zero; compose `restart: unless-stopped` backs off after 3 attempts; operator checks logs via `docker compose logs backend`.
- **Backup failure:** logged at ERROR; audit event `backup.failed`; existing retry behavior in `BackupService`.
## 12. Testing
### 12.1 New unit tests
- `tests/test_auth.py` (~12 tests)
- `test_create_user_with_argon2_hash`
- `test_login_with_correct_password_returns_session`
- `test_login_with_wrong_password_increments_failed_count`
- `test_login_locks_account_after_5_failures`
- `test_login_lock_clears_after_15min`
- `test_session_cookie_is_httponly_secure_samesite_lax`
- `test_session_expires_after_sliding_window`
- `test_session_absolute_lifetime_enforced`
- `test_logout_deletes_session_and_clears_cookie`
- `test_change_password_invalidates_other_sessions`
- `test_require_role_rejects_below_minimum`
- `test_require_role_returns_user_object_to_dependency`
- `tests/test_users.py` (~6 tests)
- `test_admin_can_create_user`
- `test_operator_cannot_create_user`
- `test_admin_can_change_user_role`
- `test_admin_can_disable_user`
- `test_disabled_user_cannot_login`
- `test_audit_event_for_user_lifecycle`
Total new backend tests: **~18**.
### 12.2 Docker smoke tests
- `tests/test_docker.py` (~4 tests)
- `test_compose_config_validates`
- `test_dockerfile_backend_builds`
- `test_dockerfile_frontend_builds`
- `test_compose_up_brings_up_healthy_stack` (uses `testcontainers-python` or skips if not available; gated on a `DOCKER_TESTS=1` env var)
### 12.3 Smoke script
`scripts/smoke.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
# 1. Bring up stack
docker compose up -d --build
# 2. Wait for healthcheck
for i in {1..30}; do
if curl -fs http://localhost:8080/api/healthz > /dev/null; then break; fi
sleep 2
done
# 3. Login
COOKIE_JAR=$(mktemp)
curl -fs -c "$COOKIE_JAR" -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d "{\"username\":\"admin\",\"password\":\"$(cat /etc/cyclone/secrets/admin_pw)\"}"
# 4. Parse a sample 837
curl -fs -b "$COOKIE_JAR" -X POST http://localhost:8080/api/parse-837 \
-F "file=@docs/goodclaim.x12"
# 5. List batches
curl -fs -b "$COOKIE_JAR" http://localhost:8080/api/batches
# 6. Export
curl -fs -b "$COOKIE_JAR" -X POST http://localhost:8080/api/batches/<id>/export-837 \
-H 'Content-Type: application/json' -d '{"claim_ids":["..."]}' \
-o /tmp/export.zip
unzip -l /tmp/export.zip | grep -E '\.x12$'
echo "smoke: OK"
```
### 12.4 Existing tests
- All existing backend tests must continue to pass
- All existing frontend tests must continue to pass
- Pre-existing prodfile smoke failures (rate-limit / orphan-set refs on 999/TA1) remain pre-existing and out of scope
## 13. Migration / rollout
### 13.1 First-time deploy
For a brand-new install on a fresh Ubuntu 22.04 server, follow §9.5 above. No data to migrate — this is the first deployment.
### 13.2 Upgrading an existing dev install
If an operator has been running `python -m cyclone serve` locally with the in-memory store or a plaintext SQLite file, the upgrade path is:
1. **Stop the existing server.**
2. **Export any production data:** SQLite is at `~/.local/share/cyclone/cyclone.db`. The operator takes a final plaintext backup *before* the SQLCipher transition; we don't try to encrypt-in-place (would need a different migration).
3. **Run `scripts/cyclone-init.sh`** to generate secrets.
4. **Run `docker compose up -d`** — the backend starts with an empty SQLCipher DB. Migrations run.
5. **Bootstrap admin user** via the CLI in the container.
6. **Re-import data** from the plaintext export (manual step; documented in RUNBOOK).
The in-memory store from the parent spec is *lost* on upgrade (by design — local-only). The plaintext SQLite DB *can* be carried over manually if needed.
### 13.3 Updating the running stack
```bash
cd /opt/cyclone
docker compose pull # or: docker compose build
docker compose up -d # recreates containers
docker compose logs -f backend | head -200 # verify migrations + healthcheck
```
DB migrations run automatically on backend start; they're forward-only. To roll back the code (not the schema): `TAG=0.0.9 docker compose up -d`.
## 14. Out of scope (explicit)
- Real SFTP wire (paramiko)
- Public TLS / domain / Caddy reverse proxy in compose
- Multi-host HA / DB replication / load balancing
- Prometheus / Grafana / alerting beyond the simple email cron
- Off-box automated backup shipping (operator's cron)
- 2FA / SSO / OAuth
- Self-service password reset
- Per-file encryption of prodfiles / sftp_staging
- LUKS full-disk encryption
- Watchtower / automatic updates
- Linting/pre-commit/dev tooling polish
- Component / E2E browser tests (Playwright)
## 15. Acceptance checklist
### 15.1 Backend
- [ ] `pytest tests/test_auth.py tests/test_users.py tests/test_docker.py -v` passes
- [ ] All previously-passing tests still pass; new total ≥ previous + 18
- [ ] `docker compose config` validates with no errors
- [ ] `docker compose build` succeeds for both services
- [ ] `docker compose up -d` brings both containers to `healthy` within 60s
- [ ] `curl http://localhost:8080/api/healthz` returns `{db_ok: true, scheduler_running: true}`
### 15.2 Auth + RBAC
- [ ] `POST /api/auth/login` with correct creds sets `cyclone_session` cookie (HttpOnly, Secure, SameSite=Lax) and returns `{user: {...}}`
- [ ] 5 wrong logins within 15min locks the account; 423 returned
- [ ] `GET /api/batches` without session cookie returns 401
- [ ] `GET /api/admin/users` as operator returns 403; as admin returns list
- [ ] Logout clears the cookie and invalidates the session in the DB
- [ ] Password change invalidates other sessions for that user
### 15.3 Encryption + backups
- [ ] `cyclone.db_crypto.is_encryption_enabled()` returns True in container
- [ ] Inspecting the DB file on disk shows binary ciphertext, not plaintext
- [ ] `POST /api/admin/backup/create` creates an encrypted `.bin` in `/var/lib/cyclone/backups/`
- [ ] Backup scheduler runs every 24h (`docker compose logs backend | grep backup-scheduler`)
- [ ] Restore flow works end-to-end: create backup → wipe DB → restore → verify data present
### 15.4 Frontend
- [ ] Unauthenticated browser request to `/` redirects to `/login`
- [ ] Login as admin → land on `/`; NavBar shows username + role
- [ ] Operator does not see "Admin" link in NavBar
- [ ] `/admin/users` as operator shows "Forbidden" page
- [ ] Logout button clears session and returns to `/login`
- [ ] `npm run build` produces a `dist/` that serves correctly from nginx
### 15.5 Smoke
- [ ] `scripts/smoke.sh` passes end-to-end against a fresh stack
- [ ] `RUNBOOK.md` exists and contains the §9.6 procedures
- [ ] `scripts/post-deploy.sh` sets up logrotate + healthcheck cron without errors
+78
View File
@@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
vi.mock("./api", () => ({
authApi: {
me: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
},
}));
import { authApi } from "./api";
import { AuthProvider, useAuth } from "./AuthProvider";
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
describe("AuthProvider", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("loads user on mount", async () => {
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 1,
username: "alice",
role: "user",
});
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.status).toBe("authenticated"));
expect(result.current.user?.username).toBe("alice");
});
it("status is unauthenticated when /me fails", async () => {
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("401")
);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.status).toBe("unauthenticated"));
expect(result.current.user).toBeNull();
});
it("login() updates state on success", async () => {
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("401")
);
(
authApi.login as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({ id: 1, username: "alice", role: "admin" });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.status).toBe("unauthenticated"));
await act(async () => {
await result.current.login("alice", "pw");
});
expect(result.current.user?.username).toBe("alice");
expect(result.current.status).toBe("authenticated");
});
it("logout() clears state", async () => {
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 1,
username: "alice",
role: "user",
});
(
authApi.logout as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue(undefined);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.status).toBe("authenticated"));
await act(async () => {
await result.current.logout();
});
expect(result.current.user).toBeNull();
expect(result.current.status).toBe("unauthenticated");
});
});
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState, useCallback, type ReactNode } from "react";
import { AuthContext, type AuthContextValue, type AuthStatus } from "./useAuth";
import { authApi } from "./api";
import type { User } from "@/types";
// Re-export useAuth so consumers can import both the context provider and
// the hook from the same module. The hook itself lives in `./useAuth` so
// the type definitions stay tree-shakeable independent of the provider.
export { useAuth } from "./useAuth";
/**
* Wraps the app and exposes the auth context. On mount it probes
* /api/auth/me to decide whether the existing `cyclone_session`
* cookie is still valid. Callers (route guard, sidebar, RoleGate,
* Login page) read the resulting `status` + `user` via `useAuth()`.
*
* - "loading" while the probe is in flight
* - "authenticated" probe succeeded; user populated
* - "unauthenticated" probe failed; user is null
*
* `login` and `logout` mutate local state directly so the UI flips
* without waiting for a second /me round-trip.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<AuthStatus>("loading");
const [user, setUser] = useState<User | null>(null);
const refresh = useCallback(async () => {
setStatus("loading");
try {
const me = await authApi.me();
setUser(me as User);
setStatus("authenticated");
} catch {
setUser(null);
setStatus("unauthenticated");
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const login = useCallback(async (username: string, password: string) => {
const u = await authApi.login(username, password);
setUser(u as User);
setStatus("authenticated");
}, []);
const logout = useCallback(async () => {
await authApi.logout().catch(() => undefined);
setUser(null);
setStatus("unauthenticated");
}, []);
const value: AuthContextValue = { status, user, login, logout, refresh };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
+80
View File
@@ -0,0 +1,80 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { cleanup } from "@testing-library/react";
vi.mock("./useAuth", () => ({
useAuth: vi.fn(),
}));
import { useAuth } from "./useAuth";
import { RoleGate } from "./RoleGate";
function mockUser(role: "admin" | "user" | "viewer" | null) {
(useAuth as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
user: role ? { id: 1, username: "x", role } : null,
status: role ? "authenticated" : "unauthenticated",
});
}
describe("RoleGate", () => {
beforeEach(() => {
vi.resetAllMocks();
cleanup();
});
it("renders children when role is allowed", () => {
mockUser("admin");
const { container } = render(
<RoleGate allow={["admin", "user"]}>
<button>Do thing</button>
</RoleGate>
);
expect(container.querySelector("button")).not.toBeNull();
expect(container.textContent).toContain("Do thing");
});
it("renders disabled (via span wrap) when role is NOT allowed", () => {
mockUser("viewer");
const { container } = render(
<RoleGate allow={["admin", "user"]}>
<button>Do thing</button>
</RoleGate>
);
const buttons = container.querySelectorAll("button");
expect(buttons.length).toBeGreaterThan(0);
// The single child element is wrapped in a span that carries the
// disabled visual signal — opacity-50 plus pointer-events-none so
// the inner control can't be clicked.
const wrapper = container.querySelector("span");
expect(wrapper).not.toBeNull();
expect(wrapper?.className).toContain("opacity-50");
expect(wrapper?.className).toContain("pointer-events-none");
// The wrapper also carries a native `title` attribute that
// explains why the affordance is disabled.
expect(wrapper?.getAttribute("title")).toMatch(/role \(viewer\) cannot perform this action/);
});
it("renders fallback when provided and role not allowed", () => {
mockUser("viewer");
const { container } = render(
<RoleGate allow={["admin"]} fallback={<span>NO</span>}>
<button>Do thing</button>
</RoleGate>
);
expect(container.textContent).toContain("NO");
// Fallback path does NOT wrap children — original button is absent.
expect(container.querySelector("button")).toBeNull();
});
it("renders nothing when user is null", () => {
mockUser(null);
const { container } = render(
<RoleGate allow={["admin", "user"]}>
<button>Do thing</button>
</RoleGate>
);
expect(container.querySelector("button")).toBeNull();
expect(container.textContent).not.toContain("Do thing");
});
});
+57
View File
@@ -0,0 +1,57 @@
import type { ReactNode } from "react";
import { useAuth } from "./useAuth";
type Role = "admin" | "user" | "viewer";
interface Props {
/**
* Roles allowed to interact with the gated affordance. The user's
* role must be in this list for children to render normally.
*/
allow: Role[];
children: ReactNode;
/**
* What to render instead when the user lacks the role AND we don't
* want to show the disabled affordance at all (e.g. "contact your
* admin" hint, or just nothing).
*/
fallback?: ReactNode;
}
/**
* RoleGate wraps any write affordance (parse button, resubmit action,
* "match selected", etc.) so it visibly disables itself when the
* current user's role isn't in `allow`.
*
* Behavior matrix:
*
* - user is null render nothing (the route guard
* already redirects to /login; this
* is belt-and-braces).
* - user.role in `allow` render children unchanged.
* - user.role NOT in `allow` and
* `fallback` is provided render `fallback`.
* - user.role NOT in `allow` and
* no `fallback` wrap children in a span carrying
* `opacity-50` + `pointer-events-none`
* + a `title` attribute that
* explains why. We use the native
* HTML `title` instead of a custom
* Tooltip component to keep this
* dependency-free and accessible
* by default.
*/
export function RoleGate({ allow, children, fallback }: Props) {
const { user } = useAuth();
if (!user) return null;
if (allow.includes(user.role)) return <>{children}</>;
if (fallback !== undefined) return <>{fallback}</>;
return (
<span
className="pointer-events-none opacity-50 inline-flex"
title={`Your role (${user.role}) cannot perform this action.`}
>
{children}
</span>
);
}
+83
View File
@@ -0,0 +1,83 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("auth/api fetch wrapper", () => {
let originalFetch: typeof globalThis.fetch;
let originalLocation: Location;
beforeEach(() => {
originalFetch = globalThis.fetch;
originalLocation = window.location;
delete (window as any).__navCalls;
(window as any).__navCalls = [];
});
afterEach(() => {
globalThis.fetch = originalFetch;
window.location = originalLocation as any;
});
it("redirects to /login on 401 response", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: "Unauthorized",
headers: new Headers(),
json: async () => ({ error: "session_expired" }),
} as Response);
// Stub window.location.href setter to capture navigation without
// actually navigating (jsdom does not implement location.href assignment).
let href = originalLocation.href;
Object.defineProperty(window, "location", {
configurable: true,
get: () => ({
...originalLocation,
get href() {
return href;
},
set href(v: string) {
(window as any).__navCalls.push(v);
href = v;
},
}),
});
const { authedFetch } = await import("./api");
await expect(authedFetch("/api/anything")).rejects.toThrow();
expect((window as any).__navCalls).toContainEqual(expect.stringContaining("/login"));
});
it("does NOT redirect on 403", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: "Forbidden",
headers: new Headers(),
json: async () => ({ error: "forbidden" }),
} as Response);
Object.defineProperty(window, "location", {
configurable: true,
get: () => ({
...originalLocation,
set href(_: string) {
throw new Error("should not nav");
},
}),
});
const { authedFetch } = await import("./api");
await expect(authedFetch("/api/anything")).rejects.toThrow();
});
it("returns parsed JSON on 200", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => ({ hello: "world" }),
} as Response);
const { authedFetch } = await import("./api");
const data = await authedFetch("/api/anything");
expect(data).toEqual({ hello: "world" });
});
});
+131
View File
@@ -0,0 +1,131 @@
/**
* Auth-aware fetch wrapper.
*
* Every authenticated backend call in the SPA goes through `authedFetch`.
* Responsibilities:
*
* 1. Attach `credentials: "include"` so the `cyclone_session` HttpOnly
* cookie rides along on cross-origin XHR calls.
* 2. Tag every request with `Accept: application/json` by default so
* the FastAPI backend knows to return its structured error shape
* (`{"error": "...", "detail": "..."}`) on failures.
* 3. On a 401 from anything OTHER than the auth endpoints themselves,
* redirect to `/login?next=<current>` so the operator sees a real
* sign-in screen instead of an infinite stream of failing queries.
* 401 from `/api/auth/*` is the normal "bad password" path let
* the caller handle it.
* 4. On any other non-2xx, parse the JSON body and throw an `ApiError`
* carrying `status`, the backend's `error` code, and the `detail`
* string. The Login page and hooks both branch on `.code`.
* 5. On 204, return `undefined` (so callers can `await` without
* blowing up on `res.json()` of an empty body).
* 6. Otherwise return the parsed JSON.
*
* `authApi` is the typed wrapper around the three auth endpoints
* (`/api/auth/login`, `/api/auth/me`, `/api/auth/logout`).
*/
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
function joinUrl(path: string): string {
if (BASE_URL) return `${BASE_URL.replace(/\/$/, "")}${path}`;
return path;
}
/**
* Error thrown for any non-2xx `authedFetch` response. Carries the HTTP
* `status`, the backend's `error` code (so callers can branch on
* `err.code === "invalid_credentials"`, etc.), and the optional `detail`
* string for surfacing in toasts.
*
* Distinct from the `ApiError` in `src/lib/api.ts` that one only
* carries `status` and is used by the existing pages; this one is the
* richer auth-aware variant that the Login page + hooks depend on.
*/
export class ApiError extends Error {
status: number;
code: string;
detail?: string;
constructor(status: number, code: string, detail?: string) {
super(detail ?? code);
this.name = "ApiError";
this.status = status;
this.code = code;
this.detail = detail;
}
}
function redirectToLogin() {
const next = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/login?next=${next}`;
}
export async function authedFetch<T = unknown>(
path: string,
init?: RequestInit
): Promise<T> {
const res = await fetch(joinUrl(path), {
credentials: "include",
headers: { Accept: "application/json", ...(init?.headers ?? {}) },
...init,
});
if (res.status === 401 && !path.startsWith("/api/auth/")) {
redirectToLogin();
throw new ApiError(401, "session_expired");
}
if (!res.ok) {
let body: any = null;
try {
body = await res.json();
} catch {
/* no body */
}
const code = body?.error ?? "error";
const detail = body?.detail ?? res.statusText;
throw new ApiError(res.status, code, detail);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
// ---------------------------------------------------------------------------
// Auth-specific endpoints. `login` and `logout` use raw `fetch` because
// they bypass the 401-redirect behavior on purpose — a 401 from
// /api/auth/login is "wrong password", not "session expired".
// ---------------------------------------------------------------------------
export const authApi = {
async login(username: string, password: string) {
const res = await fetch(joinUrl("/api/auth/login"), {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new ApiError(
res.status,
body.error ?? "error",
body.detail ?? res.statusText
);
}
return res.json();
},
async me() {
return authedFetch("/api/auth/me");
},
async logout() {
// Fire-and-forget. A network error here is fine — the server has
// already cleared the cookie or the operator is signing out because
// they're about to be disconnected. Either way, the client should
// drop the user into the unauthenticated state.
await fetch(joinUrl("/api/auth/logout"), {
method: "POST",
credentials: "include",
}).catch(() => undefined);
},
};
+56
View File
@@ -0,0 +1,56 @@
import { createContext, useContext } from "react";
import type { User } from "@/types";
/**
* Tri-state lifecycle for the auth surface.
*
* - "loading" we haven't asked /api/auth/me yet, or the result
* is still in flight. Route guards render a
* placeholder so we don't bounce the user to
* /login before the cookie has had a chance to
* prove itself.
* - "authenticated" /me returned a real User. The rest of the app
* can safely render protected surfaces.
* - "unauthenticated" /me failed (no cookie, expired cookie, server
* down). Route guards redirect to /login.
*/
export type AuthStatus = "loading" | "authenticated" | "unauthenticated";
export interface AuthContextValue {
status: AuthStatus;
user: User | null;
/**
* Authenticate against /api/auth/login. Throws `ApiError` on failure
* (with `code` = "invalid_credentials" | "account_disabled" |
* "rate_limited" | "error"). On success, status flips to
* "authenticated" and `user` is populated.
*/
login: (username: string, password: string) => Promise<void>;
/**
* Hit /api/auth/logout and clear local state. Always succeeds
* locally even if the server is unreachable, the operator is
* effectively signed out from the SPA's perspective.
*/
logout: () => Promise<void>;
/**
* Re-run the /api/auth/me probe. Useful after a profile edit on the
* admin pages so the sidebar reflects the new role without a
* full reload.
*/
refresh: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
/**
* Read the current auth context. Throws when used outside of an
* `<AuthProvider>` so consumers fail loudly during development instead
* of silently rendering the unauthenticated branch.
*/
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used inside <AuthProvider>");
}
return ctx;
}
+192
View File
@@ -0,0 +1,192 @@
// @vitest-environment happy-dom
// AckDrawer wires `useAckDetail` (TanStack Query) and renders a Radix
// Dialog portal — both need an act-aware, DOM-backed environment or
// React logs warnings and the portal can't mount.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { afterEach, describe, it, expect, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { ApiError } from "@/lib/api";
import { AckDrawer } from "@/components/AckDrawer";
import type { Ack } from "@/types";
// Mock the hook BEFORE the import above is resolved (vitest hoists
// `vi.mock` to the top of the file regardless of where it appears
// syntactically). Mocking the hook directly — rather than mocking
// `api.getAck` — lets each test pin the hook's exact return shape
// without standing up a real `QueryClient`.
const { useAckDetail } = vi.hoisted(() => ({
useAckDetail: vi.fn(),
}));
vi.mock("@/hooks/useAckDetail", () => ({
useAckDetail,
}));
/**
* Minimal valid `Ack` fixture every required key present so the
* component typechecks. The wire shape extends with `raw_999_text`
* (and `rawJson`), per `useAckDetail`'s `AckDetail` type populated
* when the backend serves it; absent on older rows.
*/
const SAMPLE_ACK: Ack & { raw_999_text: string } = {
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "P",
parsedAt: "2026-06-20T12:00:00Z",
raw_999_text: "ISA*...*~\nGS*...*~\nST*999*0001~",
};
/**
* Configure the mocked hook's return value for a single test. The
* `refetch` default is a fresh `vi.fn()` tests that need to assert
* on it can override via `overrides.refetch`.
*/
function mockDetail(
overrides: Partial<{
data: (Ack & { raw_999_text?: string }) | null;
isLoading: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
}> = {}
) {
useAckDetail.mockReturnValue({
data: null,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
...overrides,
});
}
// happy-dom keeps `document.body` between tests; without cleanup,
// `screen.getByText(...)` would find nodes from earlier renders.
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("AckDrawer", () => {
it("test_renders_nothing_when_ackId_is_null", () => {
mockDetail({ data: null });
render(<AckDrawer ackId={null} onClose={() => {}} />);
// No ack content should be in the document when the drawer is
// closed — Radix's Dialog gates the portal on `open`.
expect(document.body.textContent).not.toContain("b-uuid-1");
});
it("test_calls_useAckDetail_with_ackId", () => {
mockDetail({ data: SAMPLE_ACK });
render(<AckDrawer ackId="42" onClose={() => {}} />);
expect(useAckDetail).toHaveBeenCalledWith("42");
});
it("test_renders_ack_summary_on_success", () => {
mockDetail({ data: SAMPLE_ACK });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// Header shows the source batch id as the title.
expect(document.body.textContent).toContain("b-uuid-1");
// Counts (3 accepted, 1 rejected, 4 received) are surfaced as
// StatTile values.
expect(document.body.textContent).toContain("3");
expect(document.body.textContent).toContain("1");
expect(document.body.textContent).toContain("4");
});
it("test_renders_ack_code_pill_with_human_label", () => {
mockDetail({ data: { ...SAMPLE_ACK, ackCode: "P" } });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// The pill renders a human label, not just the bare code letter.
expect(document.body.textContent).toContain("Partially accepted");
});
it("test_renders_skeleton_while_loading", () => {
mockDetail({ isLoading: true });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// The `Skeleton` primitive sets `aria-busy="true"` — a stable
// hook for the loading state.
expect(document.querySelectorAll('[aria-busy="true"]').length).toBeGreaterThan(0);
// And the ack id should NOT have leaked in yet.
expect(document.body.textContent).not.toContain("b-uuid-1");
});
it("test_renders_not_found_error_on_404", () => {
mockDetail({
isError: true,
error: new ApiError(404, "Ack ghost not found"),
});
render(<AckDrawer ackId="9999" onClose={() => {}} />);
const errEl = document.querySelector('[data-testid="ack-drawer-error-not_found"]');
expect(errEl).not.toBeNull();
// Body should mention "doesn't exist" — the not_found COPY key.
expect(errEl?.textContent).toContain("doesn't exist");
// And the retry button should NOT be present (not_found has no
// retry affordance — retrying a 404 won't help).
expect(document.querySelector('[data-testid="error-retry"]')).toBeNull();
});
it("test_renders_network_error_with_retry", () => {
mockDetail({ isError: true, error: new Error("network down") });
render(<AckDrawer ackId="42" onClose={() => {}} />);
const errEl = document.querySelector('[data-testid="ack-drawer-error-network"]');
expect(errEl).not.toBeNull();
expect(errEl?.textContent).toContain("Couldn't reach the server");
// Network variant shows a Retry button.
const retryBtn = document.querySelector(
'[data-testid="error-retry"]'
) as HTMLButtonElement | null;
expect(retryBtn).not.toBeNull();
});
it("test_close_button_calls_onClose", () => {
const onClose = vi.fn<() => void>();
mockDetail({ isError: true, error: new Error("network down") });
render(<AckDrawer ackId="42" onClose={onClose} />);
const closeBtn = document.querySelector(
'[data-testid="error-close"]'
) as HTMLButtonElement | null;
expect(closeBtn).not.toBeNull();
closeBtn!.click();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("test_renders_download_button_when_raw_999_text_present", () => {
mockDetail({ data: SAMPLE_ACK });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// The header action slot populates with the Download 999 button
// only when the ack detail carries raw_999_text.
const dlBtn = document.querySelector('[data-testid="ack-drawer-download"]');
expect(dlBtn).not.toBeNull();
});
it("test_omits_download_button_when_raw_999_text_absent", () => {
const data: Ack = {
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "A",
parsedAt: "2026-06-20T12:00:00Z",
};
mockDetail({ data });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// No raw_999_text → no Download button.
expect(document.querySelector('[data-testid="ack-drawer-download"]')).toBeNull();
});
});
+324
View File
@@ -0,0 +1,324 @@
import { useCallback, useState } from "react";
import { Download } from "lucide-react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { ApiError } from "@/lib/api";
import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useAckDetail, type AckDetail } from "@/hooks/useAckDetail";
import { SegmentStatusList } from "./SegmentStatusList";
interface Props {
/**
* Currently-open ack id (string), or `null` when the drawer is
* closed. The URL stores ids as strings so deep links round-trip
* cleanly; the hook does the `Number()` coercion before calling
* `api.getAck`.
*/
ackId: string | null;
/** Fired when the user dismisses the drawer (X button, Escape, etc.). */
onClose: () => void;
}
/**
* Roll a hex code into a "kind" so the drawer's error branch can
* pick the right copy. Mirrors `ProviderDrawer`'s `errorKind`
* computation.
*/
type ErrorKind = "not_found" | "network";
function AckDrawerError({
kind,
onRetry,
onClose,
}: {
kind: ErrorKind;
onRetry?: () => void;
onClose: () => void;
}) {
const COPY = {
not_found: {
eyebrow: "NOT FOUND",
message: "This 999 ACK doesn't exist or has been removed.",
},
network: {
eyebrow: "CONNECTION",
message:
"Couldn't reach the server. Check your connection and try again.",
},
} as const;
const { eyebrow, message } = COPY[kind];
return (
<div
className="flex h-full flex-col"
role="alert"
data-testid={`ack-drawer-error-${kind}`}
>
<DrillDrawerHeader eyebrow="999 ACK" title="—" onClose={onClose} />
<div className="flex flex-1 flex-col items-start gap-4 px-6 py-6">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-destructive">
{eyebrow}
</span>
<p className="text-sm text-muted-foreground max-w-sm">{message}</p>
<div className="flex items-center gap-2">
{kind === "network" && onRetry ? (
<button
type="button"
onClick={onRetry}
data-testid="error-retry"
className="rounded-md border px-2.5 py-1 text-[12.5px] font-medium hover:bg-muted"
>
Retry
</button>
) : null}
<button
type="button"
onClick={onClose}
data-testid="error-close"
className="rounded-md border px-2.5 py-1 text-[12.5px] font-medium hover:bg-muted"
>
Close
</button>
</div>
</div>
</div>
);
}
/**
* Inline ack code pill same color palette as `AcksPage`
* (`Acks.tsx`) so the badge reads the same in both surfaces.
*/
function AckCodePill({ code }: { code: AckDetail["ackCode"] }) {
const cfg =
code === "A"
? {
fg: "hsl(152 64% 30%)",
bg: "hsl(152 50% 88%)",
border: "hsl(152 64% 38% / 0.30)",
label: "Accepted",
}
: code === "R"
? {
fg: "hsl(358 70% 36%)",
bg: "hsl(358 70% 92%)",
border: "hsl(358 70% 50% / 0.30)",
label: "Rejected",
}
: code === "P"
? {
fg: "hsl(36 92% 30%)",
bg: "hsl(36 82% 88%)",
border: "hsl(36 92% 50% / 0.30)",
label: "Partially accepted",
}
: {
fg: "hsl(36 92% 30%)",
bg: "hsl(36 82% 88%)",
border: "hsl(36 92% 50% / 0.30)",
label: "Accepted w/ errors",
};
return (
<span
className="inline-flex items-center rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
style={{ color: cfg.fg, backgroundColor: cfg.bg, borderColor: cfg.border }}
>
{cfg.label}
</span>
);
}
function StatTile({
label,
value,
tone,
}: {
label: string;
value: number | string;
tone: "success" | "destructive" | "ink";
}) {
const color =
tone === "success"
? "hsl(152 64% 30%)"
: tone === "destructive"
? "hsl(358 70% 36%)"
: "hsl(var(--foreground))";
return (
<div
className="rounded-lg border px-3.5 py-3"
style={{
backgroundColor: "hsl(var(--card) / 0.4)",
borderColor: "hsl(var(--border) / 0.5)",
}}
data-testid="ack-stat"
>
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{label}
</div>
<div
className="display mono tabular-nums mt-1"
style={{ color, fontSize: 22, lineHeight: 1.1 }}
>
{value}
</div>
</div>
);
}
/**
* 999 ACK drill-down drawer (SP21 Phase 5 Task 5.2).
*
* Mirror of `ProviderDrawer` same right-anchored side-panel shell,
* same `errorKind` + `useAckDetail` shape (404 vs network), same
* skeleton-first loading state. The body is slim: rolled-up counts
* at the top, the ack code pill, the source batch id, and a
* per-segment status list. The header carries a "Download 999"
* action so the user can grab the original X12 file from the drawer
* without a second round-trip to `/api/acks/{id}/raw`.
*
* Layout mirrors `ProviderDrawer`/`ClaimDrawer`: Radix Dialog
* repositioned to the right edge as a fixed-height side panel, with
* the shared `DrillDrawerHeader` on top and a scrollable body below.
*
* Error branching:
* - `ApiError(404)` "not_found" (no retry, the ack is gone)
* - anything else "network" (retry available)
*/
export function AckDrawer({ ackId, onClose }: Props) {
const { data, isLoading, isError, error, refetch } = useAckDetail(ackId);
const errorKind: ErrorKind | null = isError
? error instanceof ApiError && error.status === 404
? "not_found"
: "network"
: null;
// Download affordance for the regenerated 999 X12 text. Lives in
// the drawer (not the page) so a deep-linked user can grab the file
// without bouncing back to /acks. `raw_999_text` may be empty for
// older rows without the field — we silently no-op rather than
// surfacing an error toast (the spec calls this a "low stakes"
// affordance).
const [downloading, setDownloading] = useState(false);
const onDownload = useCallback(async () => {
if (!data) return;
const raw =
(data as AckDetail & { raw_999_text?: string }).raw_999_text ?? "";
if (!raw || downloading) return;
setDownloading(true);
try {
const blob = new Blob([raw], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `ack-${data.sourceBatchId}.999`;
a.click();
URL.revokeObjectURL(url);
} finally {
setDownloading(false);
}
}, [data, downloading]);
const downloadAction =
data && (data as AckDetail & { raw_999_text?: string }).raw_999_text ? (
<button
type="button"
onClick={onDownload}
disabled={downloading}
aria-label="Download 999 file"
title="Download 999 file"
data-testid="ack-drawer-download"
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-medium uppercase tracking-[0.14em] mono transition-colors",
downloading && "opacity-50 cursor-not-allowed",
)}
style={{
borderColor: "hsl(var(--border) / 0.7)",
color: "hsl(var(--foreground))",
backgroundColor: "hsl(var(--card) / 0.5)",
}}
>
<Download className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
999
</button>
) : null;
return (
<Dialog open={ackId !== null} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent
className="fixed right-0 top-0 flex h-full w-full max-w-2xl flex-col translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
aria-describedby={undefined}
data-testid="ack-drawer"
>
{errorKind ? (
<AckDrawerError
kind={errorKind}
onRetry={() => {
void refetch();
}}
onClose={onClose}
/>
) : isLoading || !data ? (
<div className="flex h-full flex-col overflow-y-auto">
<DrillDrawerHeader
eyebrow="999 ACK"
title="Loading…"
onClose={onClose}
/>
<div className="space-y-2 p-6">
<Skeleton variant="row" />
<Skeleton variant="row" />
<Skeleton variant="row" />
</div>
</div>
) : (
<div
className="flex h-full flex-col overflow-y-auto"
data-testid="ack-drawer-content"
>
<DrillDrawerHeader
eyebrow="999 ACK"
title={data.sourceBatchId}
onClose={onClose}
action={downloadAction}
/>
<div className="flex flex-col divide-y divide-border/40">
<section
className="flex flex-col gap-4 px-6 py-4"
data-testid="ack-summary"
>
<div className="flex items-center gap-3 flex-wrap">
<AckCodePill code={data.ackCode} />
<span className="mono text-[12px] text-muted-foreground">
ID {data.id}
</span>
<span className="mono text-[12px] text-muted-foreground">
· {data.parsedAt ? data.parsedAt.slice(0, 10) : "—"}
</span>
</div>
<div className="grid grid-cols-3 gap-3">
<StatTile
label="Accepted"
value={data.acceptedCount}
tone="success"
/>
<StatTile
label="Rejected"
value={data.rejectedCount}
tone="destructive"
/>
<StatTile
label="Received"
value={data.receivedCount}
tone="ink"
/>
</div>
</section>
<SegmentStatusList segments={[]} />
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,150 @@
import { CheckCircle2, AlertTriangle, Minus } from "lucide-react";
/**
* One 999 ACK segment status row. The 999 spec labels each transaction
* set with a 3-char code:
*
* "A" = Accepted
* "E" = Accepted, but errors are noted (one or more segments had
* errors; the transaction set as a whole was accepted)
* "R" = Rejected
* "P" = Partially accepted (mixed some segments accepted, some
* not; a degraded success the operator must investigate)
*
* The wire shape's `rawJson` (set by the parser, see backend
* `cyclone.parsers.parsers_999`) carries an array of segment rows.
* Until that array is parsed into a stable UI shape, we render the
* rolled-up counts on the ack row (`acceptedCount` / `rejectedCount`)
* and a placeholder explaining how to read it.
*/
interface SegmentRow {
/** Loop / segment reference like "ST*999*0001" or "AK2*HC*0001". */
reference?: string;
/** "A" | "E" | "R" | "P". */
status: "A" | "E" | "R" | "P";
/** Free-form note from the parser (e.g. "SVC*HC:9450 missing"). */
note?: string;
}
interface Props {
segments: SegmentRow[];
}
/**
* Pill for one segment status. Color mirrors the badge palette used on
* the Acks page (`Acks.tsx`) so a status reads the same in both
* surfaces.
*/
function StatusPill({ status }: { status: SegmentRow["status"] }) {
const cfg = {
A: {
fg: "hsl(152 64% 30%)",
bg: "hsl(152 50% 88%)",
border: "hsl(152 64% 38% / 0.30)",
label: "Accepted",
Icon: CheckCircle2,
},
E: {
fg: "hsl(36 92% 30%)",
bg: "hsl(36 82% 88%)",
border: "hsl(36 92% 50% / 0.30)",
label: "Accepted w/ errors",
Icon: AlertTriangle,
},
R: {
fg: "hsl(358 70% 36%)",
bg: "hsl(358 70% 92%)",
border: "hsl(358 70% 50% / 0.30)",
label: "Rejected",
Icon: AlertTriangle,
},
P: {
fg: "hsl(36 92% 30%)",
bg: "hsl(36 82% 88%)",
border: "hsl(36 92% 50% / 0.30)",
label: "Partially accepted",
Icon: Minus,
},
}[status];
const Icon = cfg.Icon;
return (
<span
className="inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
style={{ color: cfg.fg, backgroundColor: cfg.bg, borderColor: cfg.border }}
>
<Icon className="h-3 w-3" strokeWidth={1.75} aria-hidden />
{cfg.label}
</span>
);
}
/**
* Per-segment status list inside the AckDrawer body (SP21 Phase 5
* Task 5.2). Renders one row per parsed 999 segment, each with the
* segment reference (when present), the parsed status code, and the
* parser's note (when present). An empty list renders a small
* explanatory note so the drawer doesn't look broken on old acks
* without the per-segment slice.
*/
export function SegmentStatusList({ segments }: Props) {
return (
<section
className="flex flex-col gap-3 px-6 py-4"
data-testid="ack-segment-list"
>
<div className="flex items-baseline justify-between gap-3">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Segment status
</span>
<span
className="mono text-[10.5px] text-muted-foreground"
data-testid="ack-segment-count"
>
{segments.length} segment{segments.length === 1 ? "" : "s"}
</span>
</div>
{segments.length === 0 ? (
<p
className="text-[12.5px] text-muted-foreground"
data-testid="ack-segment-empty"
>
No per-segment breakdown for this ack the rolled-up accepted
/ rejected counts above are the only signal.
</p>
) : (
<ul className="flex flex-col divide-y divide-border/40 border-y border-border/30">
{segments.map((seg, idx) => (
<li
key={`${seg.reference ?? idx}`}
className="flex items-start gap-3 py-2.5"
data-testid="ack-segment-row"
>
<span
className="mono text-[12px] tabular-nums text-muted-foreground shrink-0"
style={{ minWidth: 32 }}
>
{idx + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<StatusPill status={seg.status} />
{seg.reference ? (
<span className="mono text-[12px] truncate">
{seg.reference}
</span>
) : null}
</div>
{seg.note ? (
<p className="text-[12.5px] text-muted-foreground mt-1 leading-snug">
{seg.note}
</p>
) : null}
</div>
</li>
))}
</ul>
)}
</section>
);
}
+4
View File
@@ -0,0 +1,4 @@
// Barrel export for the AckDrawer module (SP21 Phase 5 Task 5.2).
export { AckDrawer } from "./AckDrawer";
export { SegmentStatusList } from "./SegmentStatusList";
+22 -5
View File
@@ -5,6 +5,7 @@ import {
Plus,
type LucideIcon,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -15,6 +16,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { DrillableCell } from "@/components/drill/DrillableCell";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type {
@@ -263,13 +265,28 @@ function RowIndicator({
// ---------------------------------------------------------------------------
function ClaimIdCell({ id }: { id: string }) {
// SP21 Phase 5 Task 5.6: each claim id is drillable to
// /claims?claim=ID so the operator can jump straight from a
// "Removed from A" or "Changed" row into the ClaimDrawer.
// DrillableCell handles e.stopPropagation internally — important
// if these rows ever get a row-level onClick. For removed claims
// that no longer exist in the DB, the ClaimDrawer's 404 state
// takes over (verified in Phase 2 testing).
const navigate = useNavigate();
return (
<span
className="font-mono text-[12px] tracking-tight"
data-testid="diff-claim-id"
<DrillableCell
onClick={() =>
navigate(`/claims?claim=${encodeURIComponent(id)}`)
}
ariaLabel={`View claim ${id} in detail`}
>
{id}
</span>
<span
className="font-mono text-[12px] tracking-tight"
data-testid="diff-claim-id"
>
{id}
</span>
</DrillableCell>
);
}
+86
View File
@@ -0,0 +1,86 @@
// Shared visual primitives used by ClaimCard837 and ClaimCard835.
//
// Both cards render validation status (Passed / Warnings / Failed) and
// a grid of stat pills (Member, Place of service, …). The exact same
// mark-up was being copy-pasted inside Upload.tsx for both cards; this
// file is the shared home.
import { AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Tri-state validation indicator.
*
* - `passed` green "Passed" with check icon
* - `hasWarnings` amber "Warnings" with triangle
* - else red "Failed" with X
*/
export function ValidationDot({
passed,
hasWarnings,
}: {
passed: boolean;
hasWarnings?: boolean;
}) {
if (passed) {
return (
<span
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-[hsl(var(--success))] font-medium"
title="Validation passed"
>
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.75} />
Passed
</span>
);
}
if (hasWarnings) {
return (
<span
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-[hsl(var(--warning))] font-medium"
title="Validation passed with warnings"
>
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={1.75} />
Warnings
</span>
);
}
return (
<span
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-destructive font-medium"
title="Validation failed"
>
<XCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
Failed
</span>
);
}
/**
* Small label + value cell for the expanded-card detail grid.
*/
export function StatPill({
label,
value,
mono = true,
}: {
label: string;
value: React.ReactNode;
mono?: boolean;
}) {
return (
<div className="flex flex-col gap-1">
<div className="mono text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70">
{label}
</div>
<div
className={cn(
"text-[13px]",
mono && "display mono"
)}
style={{ color: "hsl(var(--surface-ink))" }}
>
{value}
</div>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
import React, { act } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { createRoot, type Root } from "react-dom/client";
import { useAppStore } from "@/store";
import { ClaimCard837 } from "./ClaimCard837";
import type { ClaimOutput } from "@/types";
// Tiny fixture — just enough to exercise the card's UI surface.
const CLAIM: ClaimOutput = {
claim_id: "CLM-TEST",
subscriber: { first_name: "Jane", last_name: "Doe", member_id: "MEM-1" },
payer: { name: "Test Payer", id: "P1" },
billing_provider: { npi: "1234567890" },
claim: {
total_charge: 100,
place_of_service: "11",
frequency_code: "1",
prior_auth: null,
},
service_lines: [
{
line_number: 1,
procedure: { qualifier: "HC", code: "99213", modifiers: [] },
charge: "100",
units: "1",
unit_type: "UN",
service_date: "2026-06-01",
},
],
diagnoses: [{ qualifier: "ABK", code: "J20.9" }],
validation: { passed: true, errors: [], warnings: [] },
};
function renderCard(
props: Partial<React.ComponentProps<typeof ClaimCard837>> = {},
): {
container: HTMLDivElement;
unmount: () => void;
} {
const container = document.createElement("div");
document.body.appendChild(container);
const root: Root = createRoot(container);
const onToggleSelect = props.onToggleSelect ?? (() => {});
act(() => {
root.render(
React.createElement(
MemoryRouter,
null,
React.createElement(ClaimCard837, {
claim: CLAIM,
selected: false,
onToggleSelect,
...props,
}),
),
);
});
return {
container,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
describe("ClaimCard837 — checkbox + select interaction", () => {
beforeEach(() => {
useAppStore.setState({ parsedBatches: [] });
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders a checkbox with checked={selected}", () => {
const { container, unmount } = renderCard({ selected: true });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
);
expect(cb).not.toBeNull();
expect(cb!.checked).toBe(true);
unmount();
});
it("renders an unchecked checkbox when selected={false}", () => {
const { container, unmount } = renderCard({ selected: false });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
);
expect(cb!.checked).toBe(false);
unmount();
});
it("clicking the checkbox fires onToggleSelect with the claim_id", () => {
const onToggleSelect = vi.fn();
const { container, unmount } = renderCard({ onToggleSelect });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
act(() => {
cb.click();
});
expect(onToggleSelect).toHaveBeenCalledWith("CLM-TEST");
unmount();
});
it("clicking the checkbox does NOT toggle the card's expand state", () => {
// Regression guard: the card body is a <button> that toggles expand
// on click. The new checkbox must not bubble its click into that
// button (which would also fire onToggleSelect, double-counting).
// We assert this by checking the expanded content (the service lines
// table) is NOT in the DOM after a checkbox click.
const { container, unmount } = renderCard();
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
act(() => {
cb.click();
});
// The service lines table only renders when the card is expanded.
const table = container.querySelector("table");
expect(table).toBeNull();
unmount();
});
it("clicking the card body still toggles expand (existing behavior preserved)", () => {
const { container, unmount } = renderCard();
// The card body is a <button aria-expanded="false">. Clicking it
// should reveal the expanded details (service lines table).
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-expanded]',
);
expect(expandButton).not.toBeNull();
act(() => {
expandButton!.click();
});
// After expand, the service lines table is in the DOM.
const table = container.querySelector("table");
expect(table).not.toBeNull();
unmount();
});
it("checkbox has an accessible label that names the claim", () => {
const { container, unmount } = renderCard();
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
);
// aria-label or wrapping <label> — at minimum the input must be
// findable by an accessible name. The simplest assertion is that an
// aria-label exists on the input itself.
expect(
cb!.getAttribute("aria-label") ||
cb!.closest("label")?.textContent,
).toContain("CLM-TEST");
unmount();
});
});
+352
View File
@@ -0,0 +1,352 @@
// ClaimCard837 — the warm-paper card that represents one 837P claim in
// the Upload page's streaming section. Originally inlined in
// `src/pages/Upload.tsx`; extracted into its own file in SP9 (June 2026)
// so the per-claim select-for-export interaction can live here without
// bloating the page.
//
// Two click targets, by design (see approach A in the batch-export
// design spec): a leading checkbox column for selection, and the rest
// of the card (a <button>) for expand/collapse. Putting the checkbox
// inside the button would be an a11y anti-pattern (nested interactive
// elements).
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertTriangle,
ArrowRight,
ChevronRight,
XCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useAppStore } from "@/store";
import { fmt, toNum } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ClaimOutput, ServiceLine } from "@/types";
import { StatPill, ValidationDot } from "./ClaimCard/shared";
export interface ClaimCard837Props {
claim: ClaimOutput;
/**
* Whether this card is currently selected for the batch export.
* Controlled by the parent (Upload.tsx) via the per-claim checkbox.
* The checkbox column only renders when the parent passes
* `onToggleSelect` keeps the component backward-compatible with
* callers that don't need the export flow (e.g. the persisted
* batch's "Recent batches" view, when we add it).
*/
selected?: boolean;
/** Called with the claim's id when the user clicks the checkbox. */
onToggleSelect?: (claimId: string) => void;
}
export function ClaimCard837({ claim, selected = false, onToggleSelect }: ClaimCard837Props) {
const [open, setOpen] = useState(false);
const passed = claim.validation.passed;
const hasWarnings = claim.validation.warnings.length > 0;
// SP21 Phase 5 Task 5.7: a "See claim in detail →" link drills to
// /claims?claim=ID — but ONLY when this streamed claim_id has
// actually been persisted to a parsed batch. The Upload page
// streams claims as the parser emits them; until the user clicks
// "Save batch" the claim isn't visible to ClaimDrawer.
const parsedBatches = useAppStore((s) => s.parsedBatches);
const persistedClaimIds = useMemo(
() => new Set(parsedBatches.flatMap((b) => b.claimIds)),
[parsedBatches],
);
const canDrill = persistedClaimIds.has(claim.claim_id);
const navigate = useNavigate();
const showCheckbox = typeof onToggleSelect === "function";
return (
<div
className="rounded-lg overflow-hidden border flex"
style={{
backgroundColor: "hsl(36 22% 96%)",
borderColor: "hsl(30 14% 14% / 0.10)",
boxShadow: "inset 0 1px 0 0 hsl(0 0% 100% / 0.5)",
}}
>
{/* Selection column — only when the parent wires up selection. */}
{showCheckbox ? (
<label
className="flex items-center pl-3 pr-2 cursor-pointer shrink-0"
// Belt-and-suspenders: the label is a sibling of the button
// below, so click events don't bubble into it. stopPropagation
// here guards against future refactors that might nest the
// label inside the button.
onClick={(e) => e.stopPropagation()}
data-testid={`claim-card-select-${claim.claim_id}`}
>
<input
type="checkbox"
checked={selected}
onChange={() => onToggleSelect?.(claim.claim_id)}
aria-label={`Select claim ${claim.claim_id} for export`}
className="h-3.5 w-3.5 rounded border-border accent-accent cursor-pointer"
/>
</label>
) : null}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"flex-1 min-w-0 text-left px-4 py-3 hover:bg-[hsl(36_22%_92%)] transition-colors",
!showCheckbox && "rounded-l-lg",
)}
aria-expanded={open}
>
<div className="flex items-center gap-3">
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform shrink-0",
open && "rotate-90"
)}
strokeWidth={1.75}
style={{ color: "hsl(var(--surface-ink-3))" }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<span
className="display mono text-[12.5px]"
style={{ color: "hsl(var(--surface-ink))" }}
>
{claim.claim_id}
</span>
<span
className="text-[12px] truncate"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
{claim.subscriber.first_name} {claim.subscriber.last_name}
</span>
<span
className="text-[12px]"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
· {claim.payer.name}
</span>
</div>
<div
className="mono text-[10.5px] flex items-center gap-2 mt-1"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
<span>NPI {claim.billing_provider.npi}</span>
<span className="opacity-40">·</span>
<span>
{claim.service_lines.length} line
{claim.service_lines.length === 1 ? "" : "s"}
</span>
<span className="opacity-40">·</span>
<span>{claim.diagnoses.length} dx</span>
</div>
</div>
<div className="text-right shrink-0">
<div
className="display mono text-[14px]"
style={{ color: "hsl(var(--surface-ink))" }}
>
{fmt.usdDecimal(claim.claim.total_charge)}
</div>
<div className="mt-0.5">
<ValidationDot passed={passed} hasWarnings={hasWarnings} />
</div>
</div>
</div>
</button>
{open ? (
<div
className="basis-full border-t px-4 py-3 grid gap-4 animate-fade-in"
style={{
borderColor: "hsl(30 14% 14% / 0.08)",
backgroundColor: "hsl(36 22% 92%)",
}}
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatPill label="Member" value={claim.subscriber.member_id} />
<StatPill
label="Place of service"
value={claim.claim.place_of_service ?? "—"}
/>
<StatPill label="Frequency" value={claim.claim.frequency_code ?? "—"} />
<StatPill
label="Prior auth"
value={claim.claim.prior_auth ?? "—"}
/>
</div>
{claim.diagnoses.length > 0 ? (
<div>
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Diagnoses
</div>
<div className="flex flex-wrap gap-1.5">
{claim.diagnoses.map((d, i) => (
<Badge key={`${d.code}-${i}`} variant="muted">
{d.qualifier ? `${d.qualifier}·` : ""}
{d.code}
</Badge>
))}
</div>
</div>
) : null}
{claim.service_lines.length > 0 ? (
<div>
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Service lines
</div>
<div
className="rounded-md border overflow-x-auto"
style={{
borderColor: "hsl(30 14% 14% / 0.10)",
backgroundColor: "hsl(36 22% 98%)",
}}
>
<table className="w-full text-[12px]">
<thead style={{ backgroundColor: "hsl(36 22% 90%)" }}>
<tr className="text-left" style={{ color: "hsl(var(--surface-ink-2))" }}>
<th className="px-2.5 py-1.5 font-medium">#</th>
<th className="px-2.5 py-1.5 font-medium">Code</th>
<th className="px-2.5 py-1.5 font-medium">Mods</th>
<th className="px-2.5 py-1.5 font-medium">Date</th>
<th className="px-2.5 py-1.5 font-medium">Units</th>
<th className="px-2.5 py-1.5 font-medium text-right">Charge</th>
</tr>
</thead>
<tbody>
{claim.service_lines.map((line) => (
<ServiceLine837Row key={line.line_number} line={line} />
))}
</tbody>
</table>
</div>
</div>
) : null}
{(claim.validation.errors.length > 0 ||
claim.validation.warnings.length > 0) ? (
<div>
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Validation
</div>
<ul className="space-y-1 text-[12px]">
{claim.validation.errors.map((issue, i) => (
<li
key={`e-${i}`}
className="flex items-start gap-2 text-destructive"
>
<XCircle
className="h-3.5 w-3.5 mt-0.5 shrink-0"
strokeWidth={1.75}
/>
<span>
<span className="mono">{issue.rule}</span> {issue.message}
</span>
</li>
))}
{claim.validation.warnings.map((issue, i) => (
<li
key={`w-${i}`}
className="flex items-start gap-2 text-[hsl(var(--warning))]"
>
<AlertTriangle
className="h-3.5 w-3.5 mt-0.5 shrink-0"
strokeWidth={1.75}
/>
<span>
<span className="mono">{issue.rule}</span> {issue.message}
</span>
</li>
))}
</ul>
</div>
) : null}
{/* SP21 Phase 5 Task 5.7: drill to the persisted claim. Only
renders when this streamed claim_id has been saved into
a parsed batch (so ClaimDrawer can find it). Before
"Save batch" the claim is streaming-only and the link
would 404. */}
{canDrill ? (
<div className="pt-1">
<button
type="button"
onClick={() =>
navigate(
`/claims?claim=${encodeURIComponent(claim.claim_id)}`,
)
}
data-testid="upload-claim-drill"
aria-label={`See claim ${claim.claim_id} in detail`}
className="inline-flex items-center gap-1.5 text-[12px] mono font-semibold cursor-pointer rounded-sm px-1 -mx-1 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
style={{ color: "hsl(var(--surface-ink))" }}
>
See claim in detail
<ArrowRight className="h-3 w-3" strokeWidth={2} />
</button>
</div>
) : null}
</div>
) : null}
</div>
);
}
function ServiceLine837Row({ line }: { line: ServiceLine }) {
return (
<tr
className="border-t"
style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}
>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink))" }}
>
{line.line_number}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink))" }}
>
{line.procedure.qualifier}·{line.procedure.code}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{line.procedure.modifiers.length > 0
? line.procedure.modifiers.join(", ")
: "—"}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{line.service_date ?? "—"}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{line.units ? `${toNum(line.units)} ${line.unit_type ?? ""}`.trim() : "—"}
</td>
<td
className="px-2.5 py-1.5 mono text-right display"
style={{ color: "hsl(var(--surface-ink))" }}
>
{fmt.usdDecimal(line.charge)}
</td>
</tr>
);
}
@@ -9,6 +9,7 @@ import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ClaimDrawer } from "./ClaimDrawer";
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
import { api, ApiError } from "@/lib/api";
import type { ClaimDetail } from "@/types";
@@ -162,14 +163,22 @@ function renderDrawer(
React.createElement(
QueryClientProvider,
{ client: qc },
React.createElement(ClaimDrawer, {
claimId: props.claimId,
claims,
onClose,
onNavigate,
onToggleHelp,
})
)
// SP21 Phase 5 Task 5.8: PartiesGrid (mounted by ClaimDrawer)
// calls useDrillStack(). Wrap the drawer in DrillStackProvider
// so the hook has a context. (The provider is also mounted at
// the App root in production.)
React.createElement(
DrillStackProvider,
null,
React.createElement(ClaimDrawer, {
claimId: props.claimId,
claims,
onClose,
onNavigate,
onToggleHelp,
}),
),
),
);
});
+14 -12
View File
@@ -141,12 +141,14 @@ export function ClaimDrawer({
// Right-anchored side panel. We override the Dialog primitive's
// default centered positioning (`left-1/2 top-1/2 -translate-x-1/2
// -translate-y-1/2`) by re-asserting `right-0 top-0 translate-x-0
// translate-y-0`. `h-full` + `rounded-none` + the left border
// translate-y-0`. `h-full` + `rounded-none` + the left hairline
// give it the visual identity of a drawer rather than a modal.
// `bg-card` matches the rest of the dark instrument chrome; the
// outer shadow is a soft directional falloff to the left.
// `aria-describedby={undefined}` suppresses the Radix warning
// about a missing description — the drawer content is its own
// description and there's nothing useful to point at.
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-[color:var(--m-border-heavy)]/60 bg-[color:var(--m-surface)] p-0 shadow-[-24px_0_60px_-12px_rgba(0,0,0,0.6)]"
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border/60 bg-card p-0 shadow-[-24px_0_60px_-12px_rgba(0,0,0,0.6)]"
data-testid="claim-drawer"
aria-describedby={undefined}
>
@@ -167,7 +169,7 @@ export function ClaimDrawer({
>
<ClaimDrawerHeader claim={data} onClose={onClose} />
<div
className="flex gap-0 px-6 border-b border-[color:var(--surface-line)]/40"
className="flex gap-0 px-6 border-b border-border/40"
data-testid="claim-drawer-tabs"
>
<button
@@ -176,15 +178,15 @@ export function ClaimDrawer({
className={cn(
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
activeTab === "details"
? "text-[color:var(--surface-ink)]"
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
data-testid="tab-button-details"
data-active={activeTab === "details" ? "true" : "false"}
>
Details
{activeTab === "details" ? (
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-accent shadow-[0_0_8px_hsl(var(--accent)/0.6)]" />
) : null}
</button>
<button
@@ -193,29 +195,29 @@ export function ClaimDrawer({
className={cn(
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
activeTab === "line-reconciliation"
? "text-[color:var(--surface-ink)]"
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
data-testid="tab-button-line-reconciliation"
data-active={activeTab === "line-reconciliation" ? "true" : "false"}
>
Line Reconciliation
{activeTab === "line-reconciliation" ? (
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-accent shadow-[0_0_8px_hsl(var(--accent)/0.6)]" />
) : null}
</button>
</div>
{activeTab === "line-reconciliation" ? (
lr.loading ? (
<p
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]"
className="px-6 py-4 text-sm text-muted-foreground"
data-testid="line-reconciliation-loading"
>
Loading line reconciliation
</p>
) : lr.error ? (
<p
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]"
className="px-6 py-4 text-sm text-muted-foreground"
data-testid="line-reconciliation-error"
>
Failed to load line reconciliation.
@@ -224,7 +226,7 @@ export function ClaimDrawer({
<LineReconciliationTab data={lr.data} />
) : null
) : (
<div className="flex flex-col divide-y divide-[color:var(--surface-line)]/12">
<div className="flex flex-col divide-y divide-border/40">
<ValidationPanel validation={data.validation} />
<ServiceLinesTable
serviceLines={data.serviceLines}
@@ -32,21 +32,21 @@ export function ClaimDrawerError({ kind, onRetry, onClose }: ClaimDrawerErrorPro
return (
<div
className="flex flex-col items-start gap-4 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]"
className="flex flex-col items-start gap-4 p-6 bg-card text-foreground"
role="alert"
data-testid={`claim-drawer-error-${kind}`}
>
<div className="flex items-center gap-2">
<Icon
className="h-5 w-5 text-[color:var(--m-error)]"
className="h-5 w-5 text-destructive"
strokeWidth={1.75}
aria-hidden
/>
<span className="eyebrow text-[color:var(--m-error)]">
<span className="eyebrow text-destructive">
{eyebrow}
</span>
</div>
<p className="text-sm text-[color:var(--m-ink-secondary)] max-w-sm">{message}</p>
<p className="text-sm text-muted-foreground max-w-sm">{message}</p>
<div className="flex items-center gap-2">
{kind === "network" && onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry} data-testid="error-retry">
@@ -131,14 +131,17 @@ describe("ClaimDrawerHeader", () => {
it("test_renders_claim_id_label_and_value", () => {
const { container, unmount } = renderHeader({ id: "CLM-42" });
// The eyebrow label "Claim" is rendered as uppercase text.
// The eyebrow label "Claim" is rendered as uppercase text by
// DrillDrawerHeader.
const text = (container.textContent ?? "").toLowerCase();
expect(text).toContain("claim");
// The claim id sits in a node tagged with data-testid="header-id".
const idEl = container.querySelector('[data-testid="header-id"]');
expect(idEl).not.toBeNull();
expect(idEl?.textContent).toBe("CLM-42");
// SP21 Phase 5 Task 5.10: the claim id is now the title passed
// to DrillDrawerHeader, which renders it inside an <h2>. Find
// the h2 and assert its text matches the claim id.
const titleEl = container.querySelector("h2");
expect(titleEl).not.toBeNull();
expect(titleEl?.textContent).toBe("CLM-42");
unmount();
});
@@ -212,8 +215,11 @@ describe("ClaimDrawerHeader", () => {
const onClose = vi.fn();
const { container, unmount } = renderHeader({}, onClose);
// SP21 Phase 5 Task 5.10: the close button is now rendered by
// DrillDrawerHeader (no `data-testid`); find it via its
// accessible name instead.
const closeBtn = container.querySelector(
'[data-testid="header-close"]'
'button[aria-label="Close drawer"]'
) as HTMLButtonElement | null;
expect(closeBtn).not.toBeNull();
expect(onClose).not.toHaveBeenCalled();
@@ -226,18 +232,25 @@ describe("ClaimDrawerHeader", () => {
unmount();
});
it("test_uses_modern_palette_surface", () => {
// The drawer header anchors itself on the light surface palette
// token (matches ClaimDrawerSkeleton / ClaimDrawerError). The root
// <header> element is tagged with data-testid="claim-drawer-header"
// so we can sniff its className without coupling to the badge
// or close-button wrappers.
const { container, unmount } = renderHeader({});
it("test_uses_shared_drilldrawerheader_shell", () => {
// SP21 Phase 5 Task 5.10: the header is now a thin wrapper
// around DrillDrawerHeader — verify the wrapper is present and
// that the underlying shell is the shared one (an h2 with the
// expected Tailwind treatment). The "Claim" eyebrow + claim id
// title prove the shell rendered.
const { container, unmount } = renderHeader({ id: "CLM-42" });
const root = container.querySelector('[data-testid="claim-drawer-header"]');
expect(root).not.toBeNull();
const cls = root?.className ?? "";
expect(cls).toContain("bg-[color:var(--m-surface)]");
// The h2 is DrillDrawerHeader's title slot.
const titleEl = root?.querySelector("h2");
expect(titleEl).not.toBeNull();
expect(titleEl?.textContent).toBe("CLM-42");
// The className pattern DrillDrawerHeader uses for the title.
const cls = titleEl?.className ?? "";
expect(cls).toContain("text-[18px]");
expect(cls).toContain("font-semibold");
unmount();
});
@@ -1,11 +1,11 @@
import { useState } from "react";
import { Download, X } from "lucide-react";
import { Download } from "lucide-react";
import { Badge, type BadgeProps } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader";
import { api } from "@/lib/api";
import { downloadTextFile } from "@/lib/download";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ClaimDetail } from "@/types";
type ClaimDrawerHeaderProps = {
@@ -47,12 +47,19 @@ function badgeVariantFor(state: string): BadgeProps["variant"] {
}
/**
* Header band for the claim detail drawer (SP4).
* Header band for the claim detail drawer (SP4 refactored SP21
* Phase 5 Task 5.10).
*
* Top-left: instrument-style "Claim" eyebrow + large mono ID.
* Top-right: state badge + total billed amount + close button. The
* badge color encodes state so the user can read the drawer's status
* at a glance without scrolling.
* The shell is now the shared `DrillDrawerHeader` (same as
* `ProviderDrawer` / `AckDrawer`) eyebrow + title on the left,
* close button on the right. The right-side `action` slot carries
* the state badge, total billed amount, and the "Download 837"
* button, all of which used to live in a custom <header> block.
*
* Top-left: instrument-style "Claim" eyebrow + the claim ID.
* Top-right: state badge + total billed amount + download button.
* The badge color encodes state so the user can read the drawer's
* status at a glance without scrolling.
*/
export function ClaimDrawerHeader({
claim,
@@ -80,66 +87,48 @@ export function ClaimDrawerHeader({
}
}
return (
<header
className={cn(
"flex items-start justify-between gap-4 px-6 py-5",
"border-b border-[color:var(--m-border-heavy)]/40",
"bg-[color:var(--m-surface)]"
)}
data-testid="claim-drawer-header"
>
{/* Left: eyebrow + mono claim ID */}
<div className="flex flex-col gap-1 min-w-0">
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
Claim
</span>
// The action slot is rendered by DrillDrawerHeader to the left of
// the close button. Group the three action pieces (badge, amount,
// download) in a single flex row so they read as a unit.
const action = (
<div className="flex items-center gap-2" data-testid="claim-header-actions">
<div className="flex items-center gap-2">
<Badge
variant={badgeVariantFor(claim.state)}
data-testid="header-state"
className="uppercase tracking-[0.14em]"
>
{claim.stateLabel}
</Badge>
<span
data-testid="header-id"
className="mono text-2xl font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
data-testid="header-amount"
className="mono text-sm tabular-nums text-muted-foreground"
>
{claim.id}
{fmt.usdPrecise(claim.billedAmount)}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleDownload}
disabled={downloading}
aria-label={downloading ? "Downloading 837 file" : "Download 837 file"}
title="Download 837 file"
data-testid="header-download-837"
>
<Download className="h-4 w-4" strokeWidth={1.75} />
</Button>
</div>
);
{/* Right: state badge + total amount + download + close */}
<div className="flex items-start gap-2">
<div className="flex flex-col items-end gap-1">
<Badge
variant={badgeVariantFor(claim.state)}
data-testid="header-state"
className="uppercase tracking-[0.14em]"
>
{claim.stateLabel}
</Badge>
<span
data-testid="header-amount"
className="mono text-lg tabular-nums text-[color:var(--m-ink-primary)]"
>
{fmt.usdPrecise(claim.billedAmount)}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleDownload}
disabled={downloading}
aria-label={downloading ? "Downloading 837 file" : "Download 837 file"}
title="Download 837 file"
data-testid="header-download-837"
>
<Download className="h-4 w-4" strokeWidth={1.75} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close drawer"
data-testid="header-close"
>
<X className="h-4 w-4" strokeWidth={1.75} />
</Button>
</div>
return (
<header data-testid="claim-drawer-header">
<DrillDrawerHeader
eyebrow="Claim"
title={claim.id}
onClose={onClose}
action={action}
/>
</header>
);
}
@@ -10,7 +10,7 @@ import { Skeleton } from "@/components/ui/skeleton";
export function ClaimDrawerSkeleton() {
return (
<div
className="flex flex-col gap-6 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]"
className="flex flex-col gap-6 p-6 bg-card text-foreground"
data-testid="claim-drawer-skeleton"
aria-busy="true"
>
+5 -5
View File
@@ -35,7 +35,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
<section className="flex flex-col gap-3 px-6 py-4">
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
Diagnoses ({diagnoses.length})
</h3>
@@ -43,7 +43,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
{diagnoses.length === 0 ? (
<p
data-testid="diagnoses-empty"
className="text-sm text-[color:var(--m-ink-tertiary)]"
className="text-sm text-muted-foreground"
>
No diagnoses
</p>
@@ -58,17 +58,17 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
className="flex items-baseline gap-3 text-sm"
>
<span
className="mono text-[color:var(--m-ink-primary)] font-medium"
className="mono text-foreground font-medium"
>
{d.qualifier ? `${d.qualifier} ${d.code}` : d.code}
</span>
{desc ? (
<>
<span
className="h-px w-3 bg-[color:var(--m-border-heavy)]/30"
className="h-px w-3 bg-border/60"
aria-hidden
/>
<span className="text-[color:var(--m-ink-secondary)] italic">
<span className="text-muted-foreground italic">
{desc}
</span>
</>
@@ -31,26 +31,26 @@ export function LineReconciliationTab({
>
<div className="flex gap-5 font-mono tabular-nums text-sm">
<span data-testid="billed-total" className="flex flex-col gap-0.5">
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
<span className="text-muted-foreground eyebrow">
Billed
</span>
<span className="text-[color:var(--m-ink-primary)] font-semibold">
<span className="text-foreground font-semibold">
{fmt.usdPrecise(Number(summary.billedTotal))}
</span>
</span>
<span data-testid="paid-total" className="flex flex-col gap-0.5">
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
<span className="text-muted-foreground eyebrow">
Paid
</span>
<span className="text-[color:var(--m-ink-primary)] font-semibold">
<span className="text-foreground font-semibold">
{fmt.usdPrecise(Number(summary.paidTotal))}
</span>
</span>
<span data-testid="adjustments-total" className="flex flex-col gap-0.5">
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
<span className="text-muted-foreground eyebrow">
Adjustments
</span>
<span className="text-[color:var(--m-ink-primary)] font-semibold">
<span className="text-foreground font-semibold">
{fmt.usdPrecise(Number(summary.adjustmentTotal))}
</span>
</span>
@@ -70,7 +70,7 @@ export function LineReconciliationTab({
<div className="grid grid-cols-2 gap-4">
<div data-testid="left-column">
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2">
<h3 className="eyebrow text-muted-foreground mb-2">
Billed (837 SV1)
</h3>
{leftRows.map((row) => (
@@ -78,7 +78,7 @@ export function LineReconciliationTab({
))}
</div>
<div data-testid="right-column">
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2">
<h3 className="eyebrow text-muted-foreground mb-2">
Adjudicated (835 SVC)
</h3>
{rightRows.map((row) => (
@@ -114,7 +114,7 @@ function LineCard({
return (
<div
className="flex flex-col gap-1 py-2 px-3 mb-2 rounded-r-md transition-colors hover:bg-[color:var(--m-ink-tertiary)]/5"
className="flex flex-col gap-1 py-2 px-3 mb-2 rounded-r-md transition-colors hover:bg-muted/40"
style={{
borderLeft: `3px solid ${accentColor}`,
background: "rgba(255,255,255,0.02)",
@@ -123,9 +123,9 @@ function LineCard({
>
<div className="flex items-baseline justify-between">
<div className="font-mono text-sm">
<span className="font-semibold text-[color:var(--m-ink-primary)]">{procCode}</span>
<span className="font-semibold text-foreground">{procCode}</span>
{lineNumber !== null ? (
<span className="text-[color:var(--m-ink-tertiary)] ml-2">
<span className="text-muted-foreground ml-2">
#{lineNumber}
</span>
) : null}
@@ -146,16 +146,16 @@ function LineCard({
</span>
) : null}
</div>
<div className="font-mono tabular-nums text-xs text-[color:var(--m-ink-primary)] font-semibold">
<div className="font-mono tabular-nums text-xs text-foreground font-semibold">
{side === "left" && cl ? <span>{fmt.usdPrecise(Number(cl.charge))}</span> : null}
{side === "right" && svc ? <span>{fmt.usdPrecise(Number(svc.payment))}</span> : null}
</div>
</div>
{row.adjustments.length > 0 ? (
<ul className="text-xs font-mono text-[color:var(--m-ink-tertiary)] mt-1 space-y-0.5">
<ul className="text-xs font-mono text-muted-foreground mt-1 space-y-0.5">
{row.adjustments.map((a, i) => (
<li key={i}>
<span className="text-[color:var(--m-ink-secondary)]">{a.groupCode}-{a.reasonCode}</span>
<span className="text-foreground/80">{a.groupCode}-{a.reasonCode}</span>
<span className="ml-2 tabular-nums">{fmt.usdPrecise(Number(a.amount))}</span>
</li>
))}
@@ -56,20 +56,20 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
>
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
Matched Remittance
</h3>
<div
data-testid="matched-remit-card-inner"
className="flex flex-col gap-4 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 p-4 sm:flex-row sm:items-start sm:justify-between"
className="flex flex-col gap-4 rounded-lg border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between"
>
{/* Left: remit id + status badge + received date */}
<div className="flex min-w-0 flex-col gap-2">
<span
data-testid="matched-remit-id"
className="mono text-sm text-[color:var(--m-ink-primary)]"
className="mono text-sm text-foreground"
>
{id}
</span>
@@ -83,7 +83,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
</Badge>
<span
data-testid="matched-remit-received"
className="text-xs text-[color:var(--m-ink-tertiary)]"
className="text-xs text-muted-foreground"
>
Received {fmt.date(receivedAt)}
</span>
@@ -94,7 +94,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
<div className="flex flex-col items-start gap-2 sm:items-end">
<span
data-testid="matched-remit-total-paid"
className="display text-3xl text-[color:var(--m-ink-primary)] tabular-nums"
className="display text-3xl text-foreground tabular-nums"
>
{fmt.usdPrecise(totalPaid)}
</span>
@@ -103,7 +103,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
size="sm"
onClick={handleViewRemittance}
data-testid="view-remittance-link"
className="gap-1 text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)]"
className="gap-1 text-muted-foreground hover:text-foreground"
>
View remittance
<ArrowRight
+123 -2
View File
@@ -6,13 +6,23 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { PartiesGrid } from "./PartiesGrid";
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
import type {
ClaimDetailAddress,
ClaimDetailParties,
} from "@/types";
// Mock the api module so PayerPeekContent's usePayerSummary call
// doesn't make a real network request. The spy is set per-test in
// beforeEach.
vi.mock("@/hooks/usePayerSummary", () => ({
usePayerSummary: vi.fn(),
}));
import { usePayerSummary } from "@/hooks/usePayerSummary";
function renderIntoContainer(element: React.ReactElement): {
container: HTMLDivElement;
unmount: () => void;
@@ -20,8 +30,11 @@ function renderIntoContainer(element: React.ReactElement): {
const container = document.createElement("div");
document.body.appendChild(container);
const root: Root = createRoot(container);
// SP21 Phase 5 Task 5.8: PartiesGrid now uses useDrillStack() to
// open payer peeks. Wrap every render in a DrillStackProvider so
// the hook has a context to read from.
act(() => {
root.render(element);
root.render(<DrillStackProvider>{element}</DrillStackProvider>);
});
return {
container,
@@ -279,4 +292,112 @@ describe("PartiesGrid", () => {
unmount();
});
it("SP21 Task 5.8: payer name is drillable (renders as a button)", () => {
// The payer card's name now wraps in a button with the
// `drillable` affordance + `data-testid="party-payer-name-drill"`.
// The other two cards (billing-provider, subscriber) keep the
// name as a plain div.
const { container, unmount } = renderIntoContainer(
<PartiesGrid parties={makeParties()} />
);
const payerNameBtn = container.querySelector(
'[data-testid="party-payer-name-drill"]',
);
expect(payerNameBtn).not.toBeNull();
expect(payerNameBtn?.textContent).toContain("Aetna");
// The billing-provider and subscriber names stay plain divs.
expect(
container.querySelector(
'[data-testid="party-billing-provider-name-drill"]',
),
).toBeNull();
expect(
container.querySelector(
'[data-testid="party-subscriber-name-drill"]',
),
).toBeNull();
unmount();
});
it("SP21 Task 5.8: clicking the payer name opens the PeekModal", async () => {
// Mock the payer summary fetch so PayerPeekContent resolves
// without a real network call. The peek modal renders
// regardless of fetch state (loading skeleton → content).
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: null,
isLoading: true,
});
const { container, unmount } = renderIntoContainer(
<PartiesGrid parties={makeParties()} />
);
const payerNameBtn = container.querySelector(
'[data-testid="party-payer-name-drill"]',
) as HTMLButtonElement | null;
expect(payerNameBtn).not.toBeNull();
await act(async () => {
payerNameBtn!.click();
await Promise.resolve();
});
// The PeekModal is a Radix Dialog that portals into document.body.
// Look for the [role="dialog"] (modal) with our eyebrow "Payer".
const dialogs = document.body.querySelectorAll('[role="dialog"]');
// One dialog is the modal itself (the peek). PartiesGrid doesn't
// open a drawer, so we expect exactly one.
expect(dialogs.length).toBeGreaterThanOrEqual(1);
const peekDialog = Array.from(dialogs).find((d) =>
d.textContent?.includes("Payer"),
);
expect(peekDialog).not.toBeNull();
expect(peekDialog?.textContent).toContain("Aetna");
unmount();
});
it("SP21 Task 5.8: peek uses the X12 payer id, not the human name", () => {
// The PayerPeekContent is given the X12 payer id from
// `payer.id`. Verify the click flow passes the right id by
// checking usePayerSummary was called with "PAYER01".
const mockUsePayerSummary = vi.fn().mockReturnValue({
data: null,
isLoading: true,
});
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockImplementation(
mockUsePayerSummary,
);
const { container, unmount } = renderIntoContainer(
<PartiesGrid parties={makeParties()} />
);
// No peek yet → no usePayerSummary call.
expect(mockUsePayerSummary).not.toHaveBeenCalled();
const payerNameBtn = container.querySelector(
'[data-testid="party-payer-name-drill"]',
) as HTMLButtonElement | null;
act(() => {
payerNameBtn!.click();
});
// After the click, PayerPeekContent mounts and calls
// usePayerSummary("PAYER01") — NOT the human name "Aetna".
expect(mockUsePayerSummary).toHaveBeenCalledWith("PAYER01");
unmount();
});
});
beforeEach(() => {
// Reset the mock between tests so per-test implementations stick.
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: null,
isLoading: true,
});
});
+55 -8
View File
@@ -1,4 +1,7 @@
import type { ClaimDetail, ClaimDetailAddress } from "@/types";
import { useDrillStack } from "@/components/drill/DrillStackProvider";
import { PeekModal } from "@/components/drill/PeekModal";
import { PayerPeekContent } from "@/components/drill/PayerPeekContent";
type PartiesGridProps = {
parties: ClaimDetail["parties"];
@@ -25,7 +28,7 @@ function AddressBlock({ address }: { address: AddressLike }) {
const a = address as ClaimDetailAddress;
return (
<div
className="mt-1 border-t border-dashed border-[color:var(--m-border-heavy)]/30 pt-2 text-xs leading-relaxed text-[color:var(--m-ink-secondary)]"
className="mt-1 border-t border-dashed border-border/60 pt-2 text-xs leading-relaxed text-muted-foreground"
data-testid="party-address"
>
<div>{a.line1}</div>
@@ -48,26 +51,40 @@ function PartyCard({
name,
identity,
address,
onNameClick,
}: {
testId: string;
label: string;
name: string;
identity: React.ReactNode;
address?: AddressLike;
onNameClick?: () => void;
}) {
return (
<div
data-testid={testId}
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 px-4 py-3 transition-colors hover:border-[color:var(--m-border-heavy)]/50"
className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/30 px-4 py-3 transition-colors hover:border-border hover:bg-muted/40"
>
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
<span className="eyebrow text-muted-foreground">
{label}
</span>
<div className="display text-lg text-[color:var(--m-ink-primary)]">
{name}
</div>
{onNameClick ? (
<button
type="button"
onClick={onNameClick}
data-testid={`${testId}-name-drill`}
aria-label={`Drill into ${label.toLowerCase()} ${name}`}
className="display text-lg text-foreground text-left cursor-pointer drillable rounded-sm px-0 -mx-0"
>
{name}
</button>
) : (
<div className="display text-lg text-foreground">
{name}
</div>
)}
<div
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums"
className="mono text-[12.5px] text-muted-foreground tabular-nums"
>
{identity}
</div>
@@ -88,6 +105,13 @@ function PartyCard({
*/
export function PartiesGrid({ parties }: PartiesGridProps) {
const { billingProvider, subscriber, payer } = parties;
// SP21 Phase 5 Task 5.8: payer name opens a PeekModal on top of
// the drawer — the peek stack (DrillStackProvider) holds at most
// one peek at a time, so opening payer-peek replaces any earlier
// peek. The X12 payer id (`payer.id`) is what the peek endpoint
// expects, NOT the human name.
const { stack, openPeek, closeTop } = useDrillStack();
const topPeek = stack[stack.length - 1];
return (
<section
@@ -96,7 +120,7 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
>
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
Parties
</h3>
@@ -142,8 +166,31 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
<div>ID {payer.id}</div>
</>
}
// SP21 Phase 5 Task 5.8: payer name is drillable. Clicking
// pushes a payer peek onto the drill stack; the PeekModal
// at the bottom of this section renders when the top of
// the stack is "payer". The payer id used here is the X12
// payer_id (e.g. "SKCO0") — verified in ClaimDetailPayer
// type — which is what usePayerSummary expects.
onNameClick={() => openPeek({ kind: "payer", payerId: payer.id })}
/>
</div>
{/* SP21 Phase 5 Task 5.8: payer peek. Mounted at the bottom
of PartiesGrid so the peek sits on top of the drawer (Radix
Dialog portals). Closing the peek pops the stack. Only one
peek renders at a time (the stack caps at 1) when the
user opens a payer peek, any earlier peek is replaced. */}
{topPeek?.kind === "payer" ? (
<PeekModal
open
onClose={closeTop}
eyebrow="Payer"
title={payer.name}
>
<PayerPeekContent payerId={topPeek.payerId} />
</PeekModal>
) : null}
</section>
);
}
@@ -33,17 +33,17 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
>
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
Raw Segments ({rawSegments.length})
</h3>
<details className="group rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/40 overflow-hidden transition-colors hover:border-[color:var(--m-border-heavy)]/50">
<details className="group rounded-lg border border-border/60 bg-muted/30 overflow-hidden transition-colors hover:border-border">
<summary
className="cursor-pointer select-none px-4 py-2.5 text-sm text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)] transition-colors [&::-webkit-details-marker]:hidden flex items-center gap-2"
className="cursor-pointer select-none px-4 py-2.5 text-sm text-muted-foreground hover:text-foreground transition-colors [&::-webkit-details-marker]:hidden flex items-center gap-2"
data-testid="raw-segments-summary"
>
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[color:var(--m-ink-tertiary)] group-open:bg-[color:var(--m-accent)] transition-colors" />
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground group-open:bg-accent transition-colors" />
{rawSegments.length === 0
? `Show raw segments`
: `Show ${rawSegments.length} raw segment${rawSegments.length === 1 ? "" : "s"}`}
@@ -52,14 +52,14 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
{rawSegments.length === 0 ? (
<p
data-testid="raw-segments-empty"
className="px-4 pb-3 text-sm text-[color:var(--m-ink-tertiary)]"
className="px-4 pb-3 text-sm text-muted-foreground"
>
No segments
</p>
) : (
<pre
data-testid="raw-segments-pre"
className="overflow-x-auto whitespace-pre-wrap border-t border-[color:var(--m-border-heavy)]/20 px-4 pb-3 pt-3 text-[12px] leading-relaxed text-[color:var(--m-ink-primary)] font-mono"
className="overflow-x-auto whitespace-pre-wrap border-t border-border/40 px-4 pb-3 pt-3 text-[12px] leading-relaxed text-foreground font-mono"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{rawSegments.map((segment, i) => (
@@ -49,7 +49,7 @@ export function ServiceLinesTable({
<section className="flex flex-col gap-3 px-6 py-4">
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
Service Lines ({serviceLines.length})
</h3>
@@ -57,7 +57,7 @@ export function ServiceLinesTable({
{serviceLines.length === 0 ? (
<p
data-testid="service-lines-empty"
className="text-sm text-[color:var(--m-ink-tertiary)]"
className="text-sm text-muted-foreground"
>
No service lines
</p>
@@ -84,34 +84,34 @@ export function ServiceLinesTable({
data-line-number={line.lineNumber}
className="row-hover"
>
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
<TableCell className="font-mono text-muted-foreground">
{line.lineNumber}
</TableCell>
<TableCell className="font-mono text-[color:var(--m-ink-primary)]">
<TableCell className="font-mono text-foreground">
{formatProcedure(line)}
</TableCell>
<TableCell
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)] font-semibold"
className="text-right font-mono text-base tabular-nums text-foreground font-semibold"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{fmt.usdPrecise(line.charge)}
</TableCell>
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
<TableCell className="font-mono text-muted-foreground">
{formatUnits(line)}
</TableCell>
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
<TableCell className="font-mono text-muted-foreground">
{line.serviceDate ? fmt.date(line.serviceDate) : ""}
</TableCell>
<TableCell
data-testid="paid-cell"
className="text-right font-mono tabular-nums text-[color:var(--m-ink-primary)]"
className="text-right font-mono tabular-nums text-foreground"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{formatMoneyOrDash(lr?.paid ?? null)}
</TableCell>
<TableCell
data-testid="adjustments-cell"
className="text-right font-mono tabular-nums text-[color:var(--m-ink-secondary)]"
className="text-right font-mono tabular-nums text-muted-foreground"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{formatMoneyOrDash(lr?.adjustmentsSum ?? null)}
@@ -64,7 +64,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
>
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
State History ({history.length})
</h3>
@@ -72,14 +72,14 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
{history.length === 0 ? (
<p
data-testid="state-history-empty"
className="text-sm text-[color:var(--m-ink-tertiary)]"
className="text-sm text-muted-foreground"
>
No history events
</p>
) : (
<ol
data-testid="state-history-timeline"
className="relative ml-2 flex flex-col gap-3.5 border-l border-dashed border-[color:var(--m-border-heavy)]/40 pl-6"
className="relative ml-2 flex flex-col gap-3.5 border-l border-dashed border-border/40 pl-6"
>
{history.map((event, i) => (
<li
@@ -91,18 +91,18 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
<span
data-testid="state-history-dot"
aria-hidden
className={`absolute -left-[29px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-[color:var(--m-surface)] ${dotColorFor(event.kind)}`}
className={`absolute -left-[29px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-card ${dotColorFor(event.kind)}`}
/>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span
data-testid="state-history-kind"
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-primary)]"
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-foreground"
>
{kindLabel(event.kind)}
</span>
<span
data-testid="state-history-ts"
className="mono text-xs text-[color:var(--m-ink-secondary)] tabular-nums"
className="mono text-xs text-muted-foreground tabular-nums"
>
{fmt.date(event.ts)} · {fmt.time(event.ts)}
</span>
@@ -110,7 +110,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
{event.remittanceId ? (
<span
data-testid="history-remit-id"
className="mono text-xs text-[color:var(--m-ink-tertiary)]"
className="mono text-xs text-muted-foreground"
>
Remit {event.remittanceId}
</span>
@@ -6,8 +6,9 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it } from "vitest";
import { describe, expect, it, beforeEach } from "vitest";
import { ValidationPanel } from "./ValidationPanel";
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
import type { ClaimDetailValidation, ClaimDetailValidationIssue } from "@/types";
function renderIntoContainer(element: React.ReactElement): {
@@ -17,8 +18,11 @@ function renderIntoContainer(element: React.ReactElement): {
const container = document.createElement("div");
document.body.appendChild(container);
const root: Root = createRoot(container);
// SP21 Phase 5 Task 5.9: ValidationPanel now uses useDrillStack()
// to open validation-rule peeks. Wrap every render in a
// DrillStackProvider so the hook has a context to read from.
act(() => {
root.render(element);
root.render(<DrillStackProvider>{element}</DrillStackProvider>);
});
return {
container,
@@ -244,4 +248,129 @@ describe("ValidationPanel", () => {
unmount();
});
it("SP21 Task 5.9: rule code is drillable (renders as a button)", () => {
// The rule code in each IssueGroup now wraps in a button with
// data-testid="...-rule-drill". The other rule codes also drill
// the same way.
const { container, unmount } = renderIntoContainer(
<ValidationPanel
validation={makeValidation({
passed: false,
errors: [
makeIssue({ rule: "R050_diagnosis_present" }),
],
warnings: [
makeIssue({
rule: "R200_units_recommended",
severity: "warning",
}),
],
})}
/>
);
const errorRuleDrill = container.querySelector(
'[data-testid="validation-errors-rule-drill"]',
);
const warningRuleDrill = container.querySelector(
'[data-testid="validation-warnings-rule-drill"]',
);
expect(errorRuleDrill).not.toBeNull();
expect(errorRuleDrill?.tagName).toBe("BUTTON");
expect(errorRuleDrill?.textContent).toContain("R050_diagnosis_present");
expect(warningRuleDrill).not.toBeNull();
expect(warningRuleDrill?.tagName).toBe("BUTTON");
expect(warningRuleDrill?.textContent).toContain("R200_units_recommended");
unmount();
});
it("SP21 Task 5.9: clicking the rule code opens the PeekModal", async () => {
// Clicking the rule code in the errors sub-section pushes a
// `{ kind: "rule", rule }` peek onto the drill stack. The peek
// is a Radix Dialog portal with the eyebrow "Validation rule"
// and the rule code as the title.
const { container, unmount } = renderIntoContainer(
<ValidationPanel
validation={makeValidation({
passed: false,
errors: [
makeIssue({ rule: "R050_diagnosis_present" }),
],
})}
/>
);
const ruleBtn = container.querySelector(
'[data-testid="validation-errors-rule-drill"]',
) as HTMLButtonElement | null;
expect(ruleBtn).not.toBeNull();
await act(async () => {
ruleBtn!.click();
await Promise.resolve();
});
// The PeekModal portals to document.body as a Radix dialog. Find
// the dialog with the "Validation rule" eyebrow.
const dialogs = document.body.querySelectorAll('[role="dialog"]');
const peekDialog = Array.from(dialogs).find((d) =>
d.textContent?.includes("Validation rule"),
);
expect(peekDialog).not.toBeNull();
// Title is the rule code.
expect(peekDialog?.textContent).toContain("R050_diagnosis_present");
// Body includes the catalog description for R050.
expect(peekDialog?.textContent).toContain("Diagnosis pointer present");
unmount();
});
it("SP21 Task 5.9: unknown rule still opens the peek (fallback note)", async () => {
// Rules not in the catalog still open the peek — operators
// should be able to correlate unknown rule codes to whatever
// they were just looking at. The peek shows an "Unknown rule"
// note instead of the catalog text.
const { container, unmount } = renderIntoContainer(
<ValidationPanel
validation={makeValidation({
passed: false,
errors: [
makeIssue({ rule: "R999_totally_made_up" }),
],
})}
/>
);
const ruleBtn = container.querySelector(
'[data-testid="validation-errors-rule-drill"]',
) as HTMLButtonElement | null;
expect(ruleBtn).not.toBeNull();
await act(async () => {
ruleBtn!.click();
await Promise.resolve();
});
const dialogs = document.body.querySelectorAll('[role="dialog"]');
const peekDialog = Array.from(dialogs).find((d) =>
d.textContent?.includes("Validation rule"),
);
expect(peekDialog).not.toBeNull();
expect(peekDialog?.textContent).toContain("R999_totally_made_up");
expect(peekDialog?.textContent).toContain("Unknown rule");
unmount();
});
});
beforeEach(() => {
// The PeekModal portals to document.body as a Radix dialog. Wipe
// any leftover dialogs between tests so the "find dialog by
// eyebrow" assertions in the rule-drill tests don't see stale
// portals from a prior test.
document.body.querySelectorAll('[role="dialog"]').forEach((d) => d.remove());
});
+51 -15
View File
@@ -1,4 +1,7 @@
import { AlertCircle, AlertTriangle, CheckCircle2 } from "lucide-react";
import { useDrillStack } from "@/components/drill/DrillStackProvider";
import { PeekModal } from "@/components/drill/PeekModal";
import { ValidationRulePeekContent } from "@/components/drill/ValidationRulePeekContent";
import type { ClaimDetail } from "@/types";
type ValidationPanelProps = {
@@ -28,6 +31,13 @@ function groupByRule(issues: IssueList): Array<[string, IssueList]> {
/**
* One rule-group block: header with rule code + count chip, followed by
* the list of messages underneath.
*
* SP21 Phase 5 Task 5.9: the rule code is now drillable clicking it
* opens the validation-rule peek on top of the drawer (via the drill
* stack). The peek renders ValidationRulePeekContent for the rule
* code, falling back to a "unknown rule" note when the catalog has
* no entry. Unknown rules still render the peek so operators can
* correlate the code to whatever they were just looking at.
*/
function IssueGroup({
rule,
@@ -43,17 +53,22 @@ function IssueGroup({
testId === "validation-errors"
? "text-[color:var(--m-error)]"
: "text-[color:var(--m-warning)]";
const { openPeek } = useDrillStack();
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span
className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
<button
type="button"
onClick={() => openPeek({ kind: "rule", rule })}
data-testid={`${testId}-rule-drill`}
aria-label={`Drill into rule ${rule}`}
className="mono text-[12px] font-semibold tracking-tight text-foreground cursor-pointer drillable rounded-sm px-0 -mx-0"
>
{rule}
</span>
</button>
<span
className="inline-flex items-center rounded-full bg-[color:var(--m-ink-tertiary)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[color:var(--m-ink-secondary)] tabular-nums"
className="inline-flex items-center rounded-full bg-muted-foreground/15 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground tabular-nums"
data-testid={`${testId}-count`}
>
{issues.length}
@@ -63,7 +78,7 @@ function IssueGroup({
{issues.map((issue, idx) => (
<li
key={`${rule}-${idx}`}
className="flex items-start gap-2 text-[13px] leading-snug text-[color:var(--m-ink-secondary)]"
className="flex items-start gap-2 text-[13px] leading-snug text-muted-foreground"
data-testid={`${testId}-message`}
>
<Icon
@@ -93,6 +108,11 @@ function IssueGroup({
*/
export function ValidationPanel({ validation }: ValidationPanelProps) {
const allPassed = validation.passed && validation.warnings.length === 0;
// SP21 Phase 5 Task 5.9: peek stack for rule drill. The drill
// provider is mounted at the App root; we only read the top entry
// here so the peek renders regardless of which section pushed it.
const { stack, closeTop } = useDrillStack();
const topPeek = stack[stack.length - 1];
if (allPassed) {
return (
@@ -101,14 +121,14 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
className="flex items-center gap-2 px-6 py-3"
>
<CheckCircle2
className="h-4 w-4 text-[color:var(--m-success)]"
className="h-4 w-4 text-[hsl(var(--success))]"
strokeWidth={1.75}
aria-hidden
/>
<span className="text-[13px] font-medium text-[color:var(--m-ink-primary)]">
<span className="text-[13px] font-medium text-foreground">
All checks passed
</span>
<span className="ml-auto inline-flex items-center rounded-full bg-[color:var(--m-success)]/12 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-success)]">
<span className="ml-auto inline-flex items-center rounded-full bg-[hsl(var(--success)/0.12)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-[hsl(var(--success))]">
Valid
</span>
</section>
@@ -120,10 +140,10 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
return (
<section
className="flex flex-col gap-4 px-6 py-4 bg-[color:var(--m-surface)]"
className="flex flex-col gap-4 px-6 py-4"
data-testid="validation-panel"
>
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
<span className="eyebrow text-muted-foreground">
Validation
</span>
@@ -132,15 +152,15 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
data-testid="validation-errors"
data-rule-group="errors"
role="alert"
className="flex flex-col gap-3 border-l-2 border-[color:var(--m-error)] bg-[hsl(var(--destructive)/0.06)] px-3.5 py-3"
className="flex flex-col gap-3 border-l-2 border-[color:var(--m-error)] bg-[hsl(var(--destructive)/0.08)] px-3.5 py-3"
>
<div className="flex items-center gap-2">
<AlertCircle
className="h-3.5 w-3.5 text-[color:var(--m-error)]"
className="h-3.5 w-3.5 text-destructive"
strokeWidth={1.75}
aria-hidden
/>
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-error)]">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-destructive">
Errors ({validation.errors.length})
</span>
</div>
@@ -165,11 +185,11 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
>
<div className="flex items-center gap-2">
<AlertTriangle
className="h-3.5 w-3.5 text-[color:var(--m-warning)]"
className="h-3.5 w-3.5 text-[hsl(var(--warning))]"
strokeWidth={1.75}
aria-hidden
/>
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-warning)]">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[hsl(var(--warning))]">
Warnings ({validation.warnings.length})
</span>
</div>
@@ -185,6 +205,22 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
</div>
</div>
) : null}
{/* SP21 Phase 5 Task 5.9: validation-rule peek. Mounted at the
bottom of the panel so the peek (a Radix Dialog portal) sits
on top of the drawer. Only renders when the top of the
drill stack is a rule peek payer peek (from PartiesGrid)
wins when it's on top because the stack caps at 1 entry. */}
{topPeek?.kind === "rule" ? (
<PeekModal
open
onClose={closeTop}
eyebrow="Validation rule"
title={topPeek.rule}
>
<ValidationRulePeekContent rule={topPeek.rule} />
</PeekModal>
) : null}
</section>
);
}
+167
View File
@@ -0,0 +1,167 @@
// ---------------------------------------------------------------------------
// DominantKpiCard — the headline KPI tile.
//
// One KPI gets the dominant slot on the dashboard. It carries:
// - a thicker accent rule on the left edge
// - an oversized Instrument Serif number
// - a wide sparkline (not a 28px afterthought)
// - delta + hint row in mono
// Same paper variant as the smaller KpiCard. Designed to live INSIDE
// the statement, next to a column of 4 smaller readouts.
// ---------------------------------------------------------------------------
import { ArrowDownRight, ArrowUpRight, type LucideIcon } from "lucide-react";
import * as React from "react";
import { Sparkline } from "./Sparkline";
import { AnimatedNumber } from "./AnimatedNumber";
import { cn } from "@/lib/utils";
interface DominantKpiCardProps {
label: string;
icon?: LucideIcon;
/** Pre-rendered value node. Required unless `rawValue`+`format`
* are both supplied, in which case the AnimatedNumber is used. */
value?: React.ReactNode;
/** Used when value is a plain number — drives the AnimatedNumber. */
rawValue?: number;
/** Formatter for the AnimatedNumber. */
format?: (n: number) => string;
delta?: { value: string; direction: "up" | "down"; positive: boolean };
hint?: string;
sparkline?: number[];
/** Sparkline stroke colour. Defaults to accent. */
sparklineColor?: string;
/** Accent rail colour (left edge). Defaults to accent. */
accent?: string;
className?: string;
style?: React.CSSProperties;
}
export function DominantKpiCard({
label,
icon: Icon,
value,
rawValue,
format,
delta,
hint,
sparkline,
sparklineColor = "hsl(var(--accent))",
accent = "hsl(var(--accent))",
className,
style,
}: DominantKpiCardProps) {
const computedStyle: React.CSSProperties = {
backgroundColor: "hsl(var(--surface))",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.45), 0 1px 0 0 hsl(30 14% 22% / 0.06), inset 3px 0 0 0 hsl(0 0% 100% / 0.4)",
...style,
};
return (
<div
style={computedStyle}
className={cn(
"relative rounded-xl p-6 flex flex-col gap-4 overflow-hidden",
"border border-[hsl(30_14%_14%/_0.10)] hover:bg-[hsl(36_22%_92%)] transition-colors",
className
)}
>
{/* Accent rail on the left — thicker than the small cards */}
<div
aria-hidden
className="absolute left-0 top-5 bottom-5 w-[3px] rounded-r-sm"
style={{ backgroundColor: accent, opacity: 0.85 }}
/>
{/* Header row: label + icon */}
<div className="flex items-center justify-between">
<div
className="eyebrow flex items-center gap-2"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
<span>{label}</span>
{hint ? (
<span
className="mono normal-case tracking-normal"
style={{
color: "hsl(var(--surface-ink-3))",
fontSize: 10,
opacity: 0.7,
}}
>
· {hint}
</span>
) : null}
</div>
{Icon ? (
<div
className="h-7 w-7 rounded-md flex items-center justify-center"
style={{
backgroundColor: "hsl(36 22% 90%)",
boxShadow: "inset 0 0 0 1px hsl(30 14% 22% / 0.08)",
color: "hsl(var(--surface-ink-2))",
}}
>
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
</div>
) : null}
</div>
{/* Big number */}
<div
className="display tabular-nums tracking-[-0.04em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(48px, 6vw, 72px)",
lineHeight: 0.92,
fontWeight: 400,
}}
>
{rawValue !== undefined && format ? (
<AnimatedNumber value={rawValue} format={format} />
) : (
value
)}
</div>
{/* Delta + sparkline */}
<div className="flex items-end justify-between gap-4">
<div className="flex items-center gap-2 text-[11px] min-h-[16px]">
{delta ? (
<span
className={cn(
"inline-flex items-center gap-0.5 font-medium mono",
delta.positive
? "text-[hsl(var(--success))]"
: "text-destructive"
)}
>
{delta.direction === "up" ? (
<ArrowUpRight className="h-3 w-3" strokeWidth={2} />
) : (
<ArrowDownRight className="h-3 w-3" strokeWidth={2} />
)}
{delta.value}
</span>
) : null}
<span
className="mono uppercase tracking-[0.14em]"
style={{
color: "hsl(var(--surface-ink-3))",
fontSize: 10,
}}
>
vs last 6 mo
</span>
</div>
{sparkline ? (
<div className="flex-1 max-w-[240px]">
<Sparkline values={sparkline} stroke={sparklineColor} />
</div>
) : null}
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
// ---------------------------------------------------------------------------
// EditorialNote — italic Instrument Serif margin annotations.
//
// A small italicized serif note that "speaks" to the data next to it.
// Used in the dashboard to narrate the day's narrative: "Three NPIs
// are in flight" sits next to the KPI grid; an editorial note sits
// beside the chart explaining what it shows. The note itself is a
// single italic line of Instrument Serif with a tiny serif ampersand
// or hairline rule introducing it.
//
// This is the dashboard's "voice" — the moment the data stops being
// silent and reads as a story.
// ---------------------------------------------------------------------------
import * as React from "react";
import { cn } from "@/lib/utils";
export interface EditorialNoteProps {
children: React.ReactNode;
/** Optional lead-in (e.g. "↘", "§", "—" or a short label). */
lead?: string;
/** Optional secondary line shown below the note in mono caps. */
caption?: string;
/** Tone — dark canvas (default) or paper. */
tone?: "dark" | "paper";
className?: string;
}
export function EditorialNote({
children,
lead,
caption,
tone = "dark",
className,
}: EditorialNoteProps) {
const noteColor =
tone === "paper"
? "hsl(var(--surface-ink))"
: "hsl(var(--foreground))";
const leadColor =
tone === "paper"
? "hsl(var(--surface-ink-3))"
: "hsl(var(--muted-foreground))";
const captionColor =
tone === "paper"
? "hsl(var(--surface-ink-3))"
: "hsl(var(--muted-foreground))";
return (
<div className={cn("flex items-start gap-3", className)}>
{lead ? (
<span
aria-hidden
className="display italic shrink-0 leading-none mt-1"
style={{ color: leadColor, fontSize: 18 }}
>
{lead}
</span>
) : null}
<div className="min-w-0">
<p
className="display italic leading-snug"
style={{
color: noteColor,
fontSize: 15,
letterSpacing: "-0.005em",
}}
>
{children}
</p>
{caption ? (
<p
className="mono uppercase tracking-[0.18em] mt-1.5"
style={{ color: captionColor, fontSize: 9.5 }}
>
{caption}
</p>
) : null}
</div>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
import React, { act, useState } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { ExportBar } from "./ExportBar";
function renderBar(
props: Partial<React.ComponentProps<typeof ExportBar>> = {},
): {
container: HTMLDivElement;
unmount: () => void;
} {
const container = document.createElement("div");
document.body.appendChild(container);
const root: Root = createRoot(container);
const onToggleAll = props.onToggleAll ?? (() => {});
const onExport = props.onExport ?? (() => {});
act(() => {
root.render(
React.createElement(ExportBar, {
total: 10,
selectedCount: 10,
exporting: false,
onToggleAll,
onExport,
...props,
}),
);
});
return {
container,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
describe("ExportBar", () => {
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders the total and selected count", () => {
const { container, unmount } = renderBar({ total: 24, selectedCount: 18 });
expect(container.textContent).toContain("18");
expect(container.textContent).toContain("24");
unmount();
});
it("the select-all checkbox is checked when all items are selected", () => {
const { container, unmount } = renderBar({ total: 5, selectedCount: 5 });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
expect(cb.checked).toBe(true);
expect(cb.indeterminate).toBe(false);
unmount();
});
it("the select-all checkbox is unchecked when nothing is selected", () => {
const { container, unmount } = renderBar({ total: 5, selectedCount: 0 });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
expect(cb.checked).toBe(false);
expect(cb.indeterminate).toBe(false);
unmount();
});
it("the select-all checkbox is indeterminate when some items are selected", () => {
const { container, unmount } = renderBar({ total: 5, selectedCount: 2 });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
expect(cb.checked).toBe(false);
expect(cb.indeterminate).toBe(true);
unmount();
});
it("clicking the select-all checkbox calls onToggleAll", () => {
const onToggleAll = vi.fn();
const { container, unmount } = renderBar({ onToggleAll });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
act(() => {
cb.click();
});
expect(onToggleAll).toHaveBeenCalledTimes(1);
unmount();
});
it("the export button is disabled when selectedCount is 0", () => {
const { container, unmount } = renderBar({ selectedCount: 0 });
const btn = container.querySelector<HTMLButtonElement>("button")!;
expect(btn.disabled).toBe(true);
unmount();
});
it("the export button is enabled when selectedCount > 0", () => {
const { container, unmount } = renderBar({ selectedCount: 3 });
const btn = container.querySelector<HTMLButtonElement>("button")!;
expect(btn.disabled).toBe(false);
unmount();
});
it("the export button is disabled while exporting", () => {
const { container, unmount } = renderBar({ selectedCount: 3, exporting: true });
const btn = container.querySelector<HTMLButtonElement>("button")!;
expect(btn.disabled).toBe(true);
unmount();
});
it("clicking the export button calls onExport", () => {
const onExport = vi.fn();
const { container, unmount } = renderBar({ onExport, selectedCount: 3 });
const btn = container.querySelector<HTMLButtonElement>("button")!;
act(() => {
btn.click();
});
expect(onExport).toHaveBeenCalledTimes(1);
unmount();
});
it("renders the selected count in the export button label", () => {
const { container, unmount } = renderBar({ selectedCount: 7 });
const btn = container.querySelector<HTMLButtonElement>("button")!;
expect(btn.textContent).toContain("7");
unmount();
});
});
+127
View File
@@ -0,0 +1,127 @@
// ExportBar — sticky bar that sits at the top of the Upload page's
// streaming section and lets the user select-all / deselect-all the
// streamed claims and trigger the batch X12 export.
//
// Tri-state "Select all" checkbox:
// - all selected → checked
// - none selected → unchecked
// - mixed → indeterminate (the dash / line state)
//
// The Export button is disabled when the selection is empty (no point
// sending an empty bundle) or when an export is already in flight
// (don't double-fire the network call).
import { CheckSquare, Download, Loader2, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface ExportBarProps {
/** Total claims in the stream (denominator for "X of Y selected"). */
total: number;
/** Current selection size (numerator). */
selectedCount: number;
/** When true, the export button shows a spinner and is disabled. */
exporting: boolean;
/** Called when the user toggles the select-all checkbox. */
onToggleAll: () => void;
/** Called when the user clicks the Export button. */
onExport: () => void;
}
export function ExportBar({
total,
selectedCount,
exporting,
onToggleAll,
onExport,
}: ExportBarProps) {
const allSelected = total > 0 && selectedCount === total;
const noneSelected = selectedCount === 0;
// Indeterminate = some selected, but not all. Bound to the input via
// a ref because the `indeterminate` attribute is not a real HTML
// attribute — it's a JS property only.
const indeterminate = !allSelected && !noneSelected;
return (
<div
className={cn(
"flex items-center gap-4 px-3 py-2 rounded-md border bg-background/40",
"flex-wrap",
)}
style={{ borderColor: "hsl(var(--border) / 0.6)" }}
data-testid="export-bar"
>
<label
className="inline-flex items-center gap-2 cursor-pointer select-none text-[12px] mono uppercase tracking-[0.14em] font-semibold text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
data-testid="export-bar-toggle-all"
>
{allSelected ? (
<CheckSquare
className="h-3.5 w-3.5"
strokeWidth={1.75}
style={{ color: "hsl(var(--accent))" }}
/>
) : (
<Square
className="h-3.5 w-3.5"
strokeWidth={1.75}
style={{
color: indeterminate
? "hsl(var(--accent) / 0.7)"
: "hsl(var(--muted-foreground) / 0.6)",
}}
/>
)}
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = indeterminate;
}}
onChange={onToggleAll}
className="sr-only"
aria-label={
allSelected
? "Deselect all claims"
: noneSelected
? "Select all claims"
: `Select all claims (${selectedCount} of ${total} currently selected)`
}
/>
{allSelected ? "Deselect all" : "Select all"}
</label>
<span
className="text-[11.5px] mono text-muted-foreground/70"
aria-live="polite"
>
<span className="text-foreground">{selectedCount}</span> of{" "}
<span className="text-foreground">{total}</span> selected
</span>
<div className="ml-auto flex items-center gap-2">
<Button
size="sm"
variant="default"
onClick={onExport}
disabled={noneSelected || exporting}
data-testid="export-bar-export"
aria-label={`Export ${selectedCount} claims as X12 ZIP`}
>
{exporting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Exporting
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
Export ({selectedCount})
</>
)}
</Button>
</div>
</div>
);
}
+18
View File
@@ -24,6 +24,24 @@ export function Layout() {
return (
<div className="relative min-h-screen z-10">
<SkipLink />
{/* Ambient page halo a soft, fixed radial that creates a
subtle lighter zone behind the page content (centered
just below the top bar). Pure decoration; pointer-events
disabled so it never intercepts clicks. Two layered
radials: a neutral softbox centered above the page
header, and a cool accent in the upper-right that
mirrors the body::before composition. The halo sits
behind everything else (z-0 inside the z-10 root). */}
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 top-0 z-0 h-[70vh]"
style={{
background: `
radial-gradient(ellipse 55% 30% at 50% 6%, hsla(220, 32%, 30%, 0.16), transparent 70%),
radial-gradient(ellipse 35% 25% at 82% 10%, hsla(212, 70%, 60%, 0.10), transparent 65%)
`,
}}
/>
<div
className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none transition-opacity duration-200"
style={{ opacity: showScan ? 1 : 0 }}
+152
View File
@@ -0,0 +1,152 @@
// ---------------------------------------------------------------------------
// TickerTape — live vital-signs strip for the dashboard hero.
//
// A horizontal tape of small numeric readouts separated by hairline
// dividers, rendered on the dark canvas. Each cell shows a label
// (uppercase mono) and a value (mono). One cell can be marked
// `accent` to draw the eye, and the optional `live` cell shows a
// pulsing dot — the dashboard's only acknowledgement of time.
//
// This is a static ticker (not scrolling); the cells are just laid
// out left-to-right with dividers. We use it to give the dark hero
// data density without committing to a real chart.
// ---------------------------------------------------------------------------
import { cn } from "@/lib/utils";
export interface TickerCell {
/** Stable id used as React key. */
id: string;
/** Uppercase mono label (e.g. "Billed", "Pending", "Denial"). */
label: string;
/** The value, rendered verbatim in mono. Format it before passing. */
value: string;
/** Optional secondary value shown muted (e.g. delta). */
delta?: string;
/** Highlight this cell with the accent colour. */
accent?: boolean;
}
export interface TickerTapeProps {
cells: TickerCell[];
/** Show the "LIVE" pulse on the leading edge. */
live?: boolean;
/** Live label (default "Live"). */
liveLabel?: string;
className?: string;
}
export function TickerTape({
cells,
live = false,
liveLabel = "Live",
className,
}: TickerTapeProps) {
return (
<div
className={cn(
"relative flex items-stretch w-full overflow-hidden",
"rounded-md border border-border/50 bg-card/40 backdrop-blur",
className
)}
>
{/* Left rail — amber signal accent for the LIVE indicator */}
{live ? (
<div
className="flex items-center gap-2 px-3 border-r border-border/60 shrink-0"
style={{
background:
"linear-gradient(180deg, hsl(36 92% 56% / 0.06), transparent)",
}}
>
<span className="relative inline-flex h-1.5 w-1.5 shrink-0">
<span
className="absolute inline-flex h-full w-full rounded-full opacity-60 animate-ping"
style={{ backgroundColor: "hsl(var(--signal))" }}
/>
<span
className="relative inline-flex h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: "hsl(var(--signal))" }}
/>
</span>
<span
className="mono uppercase tracking-[0.18em] font-semibold"
style={{
color: "hsl(var(--signal))",
fontSize: 10,
}}
>
{liveLabel}
</span>
</div>
) : null}
{/* Cells */}
<div className="flex items-stretch flex-1 min-w-0 overflow-x-auto">
{cells.map((c, i) => (
<div
key={c.id}
className={cn(
"flex-1 min-w-[110px] px-4 py-2.5 flex flex-col gap-0.5",
i > 0 ? "border-l border-border/40" : ""
)}
>
<span
className="mono uppercase tracking-[0.18em] font-medium"
style={{
color: c.accent
? "hsl(var(--accent))"
: "hsl(var(--muted-foreground))",
fontSize: 9.5,
}}
>
{c.label}
</span>
<div className="flex items-baseline gap-1.5">
<span
className="display mono tabular-nums"
style={{
color: c.accent
? "hsl(var(--foreground))"
: "hsl(var(--foreground))",
fontSize: 16,
letterSpacing: "-0.01em",
}}
>
{c.value}
</span>
{c.delta ? (
<span
className="mono tabular-nums"
style={{
color: "hsl(var(--muted-foreground))",
fontSize: 10.5,
}}
>
{c.delta}
</span>
) : null}
</div>
</div>
))}
</div>
{/* Right rail perforated tick pattern, decoratively marks the
end of the tape without using a heavy border. */}
<div
aria-hidden
className="shrink-0 w-3 self-stretch"
style={{
backgroundImage:
"radial-gradient(circle, hsl(36 14% 88% / 0.16) 1px, transparent 1.5px)",
backgroundSize: "6px 6px",
backgroundPosition: "0 center",
maskImage:
"linear-gradient(to right, transparent, black 30%, black 100%)",
WebkitMaskImage:
"linear-gradient(to right, transparent, black 30%, black 100%)",
}}
/>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
// ---------------------------------------------------------------------------
// AgingBars — AR aging stacked horizontal bar.
//
// One bar split into up to 4 segments by aging bucket (030 / 3160 /
// 6190 / 90+ days). Each segment is coloured along a cool-to-warm ramp
// so the eye reads left-to-right as "freshest to most stale". Below
// the bar, four legend rows show the bucket label, dollar amount, and
// share of total. The component is data-driven — pass any four-bucket
// distribution.
// ---------------------------------------------------------------------------
import { fmt } from "@/lib/format";
export interface AgingBucket {
/** Bucket id for keying. */
id: string;
/** Display label (e.g. "030", "3160", "6190", "90+"). */
label: string;
/** Dollar amount outstanding in this bucket. */
amount: number;
/** Tailwind/HSL colour for the segment. */
color: string;
}
export interface AgingBarsProps {
buckets: AgingBucket[];
/** Total AR amount for share calc. Defaults to sum of buckets. */
total?: number;
/** Optional caption shown right-aligned above the bar. */
caption?: string;
}
export function AgingBars({ buckets, total, caption }: AgingBarsProps) {
const sum = total ?? buckets.reduce((s, b) => s + b.amount, 0);
if (sum <= 0) {
return (
<div
className="mono text-[11px] py-6 text-center"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Nothing outstanding.
</div>
);
}
return (
<div className="w-full">
{caption ? (
<div
className="mono text-[10.5px] uppercase tracking-[0.16em] mb-2 flex items-center justify-between"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
<span>Aging buckets</span>
<span>{caption}</span>
</div>
) : null}
{/* The stacked bar */}
<div
className="flex w-full h-3 overflow-hidden rounded-[3px]"
style={{ boxShadow: "inset 0 0 0 1px hsl(30 14% 14% / 0.08)" }}
>
{buckets.map((b, i) => {
const pct = (b.amount / sum) * 100;
if (pct <= 0) return null;
return (
<div
key={b.id}
className="h-full"
style={{
width: `${pct}%`,
backgroundColor: b.color,
animation: `bar-grow-h 800ms cubic-bezier(0.2,0.8,0.2,1) ${
i * 70
}ms both`,
transformOrigin: "left center",
}}
title={`${b.label}: ${fmt.usd(b.amount)}`}
aria-label={`${b.label} days: ${fmt.usd(b.amount)}`}
/>
);
})}
</div>
{/* Legend rows */}
<ul className="mt-4 space-y-2">
{buckets.map((b) => {
const share = sum > 0 ? (b.amount / sum) * 100 : 0;
return (
<li
key={b.id}
className="flex items-center justify-between gap-3 text-[12px]"
>
<div className="flex items-center gap-2 min-w-0">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-sm shrink-0"
style={{ backgroundColor: b.color }}
/>
<span
className="mono tabular-nums"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
{b.label}d
</span>
</div>
<div className="flex items-center gap-3 shrink-0">
<div
className="relative h-1 w-16 rounded-full overflow-hidden"
style={{ backgroundColor: "hsl(30 14% 14% / 0.06)" }}
aria-hidden
>
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${share}%`,
backgroundColor: b.color,
opacity: 0.7,
}}
/>
</div>
<span
className="mono tabular-nums"
style={{
color: "hsl(var(--surface-ink))",
minWidth: 70,
textAlign: "right",
}}
>
{fmt.usd(b.amount)}
</span>
<span
className="mono tabular-nums"
style={{
color: "hsl(var(--surface-ink-3))",
minWidth: 40,
textAlign: "right",
fontSize: 10.5,
}}
>
{share.toFixed(0)}%
</span>
</div>
</li>
);
})}
</ul>
</div>
);
}
+381
View File
@@ -0,0 +1,381 @@
// ---------------------------------------------------------------------------
// BarChart — grouped vertical bar chart on cream paper.
//
// Renders N series (e.g. billed, received, AR) over M categories (months).
// Each category gets a cluster of bars, one per series. The chart grows
// the bars from 0 on mount with a staggered delay so the statement
// doesn't appear all at once. Hover shows the exact value as a small
// ledger callout. Designed to live INSIDE the paper statement — no dark
// chrome, hairline grid only.
//
// The paper variant: cream background, dark ink, hairline rule in
// --surface-line-soft. Three series uses --accent (billed), --success
// (received), --signal (AR outstanding) so each carries its own
// semantic colour rather than three random hues.
// ---------------------------------------------------------------------------
import { useEffect, useRef, useState } from "react";
export interface BarSeries {
/** Stable id used for keying, hover hit-tests, and legend swatches. */
id: string;
/** Display name shown in the legend. */
label: string;
/** Hex/HSL colour used for bar fill + legend swatch. */
color: string;
/** Values aligned with `categories`. */
values: number[];
/** Optional formatter for tooltip + axis labels. Defaults to identity. */
format?: (n: number) => string;
}
export interface BarChartProps {
/** Category labels along the X axis (e.g. month abbreviations). */
categories: string[];
/** Ordered series. First series renders leftmost in each cluster. */
series: BarSeries[];
/** Height in px. Default 260. */
height?: number;
/** Optional y-axis tick formatter. If omitted, the chart self-scales
* to a "round" max and labels with a compact thousands suffix. */
formatY?: (n: number) => string;
/** When true, draws a hairline rule for each y tick. Default true. */
showGrid?: boolean;
/** Force a specific Y-axis maximum. When omitted, the chart auto-scales
* to a "round" max with a small headroom. */
yMax?: number;
/** When true, prints the per-cluster total above each month. Default true. */
showTotals?: boolean;
/** Index of the "current" category to highlight (e.g. latest month).
* Adds a subtle background band and an eyebrow above. */
highlightIndex?: number;
}
const PAD_LEFT = 44;
const PAD_RIGHT = 16;
const PAD_TOP = 16;
const PAD_BOTTOM = 28;
const niceMax = (raw: number): number => {
if (raw <= 0) return 1;
const exp = Math.floor(Math.log10(raw));
const base = Math.pow(10, exp);
const norm = raw / base;
let nice: number;
if (norm <= 1) nice = 1;
else if (norm <= 2) nice = 2;
else if (norm <= 2.5) nice = 2.5;
else if (norm <= 5) nice = 5;
else nice = 10;
return nice * base;
};
const compact = (n: number): string => {
const abs = Math.abs(n);
if (abs >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
if (abs >= 1_000) return `$${(n / 1_000).toFixed(0)}k`;
return `$${n.toFixed(0)}`;
};
interface Hover {
cat: number;
series: number;
x: number;
y: number;
}
export function BarChart({
categories,
series,
height = 260,
formatY,
showGrid = true,
yMax: yMaxProp,
showTotals = true,
highlightIndex,
}: BarChartProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const [width, setWidth] = useState(800);
const [hover, setHover] = useState<Hover | null>(null);
// Track container width so the SVG reflows with the layout grid.
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 800;
setWidth(Math.max(320, Math.floor(w)));
});
ro.observe(el);
setWidth(Math.max(320, Math.floor(el.clientWidth)));
return () => ro.disconnect();
}, []);
const innerW = width - PAD_LEFT - PAD_RIGHT;
const innerH = height - PAD_TOP - PAD_BOTTOM;
const allValues = series.flatMap((s) => s.values);
const rawMax = allValues.length ? Math.max(...allValues) : 1;
// If caller forces a yMax, use it; otherwise compute a "round" max
// with a small (5%) headroom so the tallest bar doesn't kiss the lid.
const yMax = yMaxProp ?? niceMax(rawMax * 1.05);
const yTicks = 4;
const clusterW = innerW / Math.max(1, categories.length);
const barGap = 4;
const barW = Math.max(
4,
(clusterW - barGap * (series.length - 1)) / Math.max(1, series.length)
);
const yToPx = (v: number) => PAD_TOP + innerH - (v / yMax) * innerH;
const catCenterX = (i: number) => PAD_LEFT + clusterW * i + clusterW / 2;
const fmtY = formatY ?? compact;
// Stagger the bars so they grow from baseline on first paint. We just
// emit a CSS animation-delay per bar via inline style.
const barIndex = (catI: number, serI: number) =>
catI * series.length + serI;
return (
<div ref={wrapRef} className="relative w-full" style={{ height }}>
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="block"
role="img"
aria-label="Monthly billed, received, and AR trend"
onMouseLeave={() => setHover(null)}
>
{/* Y-axis grid + labels */}
{showGrid &&
Array.from({ length: yTicks + 1 }, (_, i) => {
const v = (yMax / yTicks) * i;
const y = yToPx(v);
return (
<g key={i}>
<line
x1={PAD_LEFT}
x2={width - PAD_RIGHT}
y1={y}
y2={y}
stroke="hsl(30 14% 14% / 0.10)"
strokeWidth={1}
strokeDasharray={i === 0 ? "" : "2 3"}
/>
<text
x={PAD_LEFT - 10}
y={y + 4}
textAnchor="end"
className="mono"
fontSize={11}
fontWeight={500}
style={{ fill: "hsl(var(--surface-ink-2))" }}
>
{fmtY(v)}
</text>
</g>
);
})}
{/* Highlight band on the current month (subtle, like a sticky note) */}
{highlightIndex !== undefined &&
highlightIndex >= 0 &&
highlightIndex < categories.length ? (
<g>
<rect
x={catCenterX(highlightIndex) - clusterW / 2}
y={PAD_TOP}
width={clusterW}
height={innerH}
fill="hsl(36 92% 56% / 0.06)"
/>
<line
x1={catCenterX(highlightIndex)}
x2={catCenterX(highlightIndex)}
y1={PAD_TOP}
y2={PAD_TOP + innerH}
stroke="hsl(36 92% 56% / 0.45)"
strokeWidth={1}
strokeDasharray="2 3"
/>
<text
x={catCenterX(highlightIndex)}
y={PAD_TOP + 12}
textAnchor="middle"
className="mono"
fontSize={9}
fontWeight={600}
style={{ fill: "hsl(36 92% 36%)", letterSpacing: "0.18em" }}
>
NOW
</text>
</g>
) : null}
{/* Bars */}
{categories.map((cat, ci) =>
series.map((s, si) => {
const v = s.values[ci] ?? 0;
const x =
catCenterX(ci) -
(series.length * barW + (series.length - 1) * barGap) / 2 +
si * (barW + barGap);
const y = yToPx(v);
const h = Math.max(0, PAD_TOP + innerH - y);
const delay = barIndex(ci, si) * 35;
const isHover =
hover?.cat === ci && hover?.series === si ? true : false;
return (
<rect
key={`${cat}-${s.id}`}
x={x}
y={y}
width={barW}
height={h}
rx={1.5}
fill={s.color}
opacity={hover && !isHover ? 0.55 : 1}
style={{
transformOrigin: `${x + barW / 2}px ${PAD_TOP + innerH}px`,
animation: `bar-grow 600ms cubic-bezier(0.2,0.8,0.2,1) ${delay}ms both`,
}}
onMouseEnter={() =>
setHover({
cat: ci,
series: si,
x: x + barW / 2,
y,
})
}
/>
);
})
)}
{/* X-axis labels */}
{categories.map((cat, i) => (
<text
key={cat}
x={catCenterX(i)}
y={height - 14}
textAnchor="middle"
className="mono"
fontSize={11}
fontWeight={500}
style={{
fill:
highlightIndex === i
? "hsl(var(--surface-ink))"
: "hsl(var(--surface-ink-2))",
}}
>
{cat}
</text>
))}
{/* Per-cluster totals above each month — quick read of the spine. */}
{showTotals &&
categories.map((_, ci) => {
const total = series.reduce(
(s, sr) => s + (sr.values[ci] ?? 0),
0
);
const x = catCenterX(ci);
const topY = yToPx(
Math.max(...series.map((s) => s.values[ci] ?? 0))
);
const labelY = Math.max(14, topY - 8);
return (
<text
key={`total-${ci}`}
x={x}
y={labelY}
textAnchor="middle"
className="display mono"
fontSize={11}
fontWeight={600}
style={{
fill: "hsl(var(--surface-ink-2))",
opacity: 0.75,
}}
>
{fmtY(total)}
</text>
);
})}
{/* Hover callout */}
{hover ? (
(() => {
const s = series[hover.series]!;
const v = s.values[hover.cat] ?? 0;
const catLabel = categories[hover.cat] ?? "";
const text = `${s.label} · ${catLabel}`;
const valueText = s.format ? s.format(v) : fmtY(v);
// Position the chip to the right of the bar; flip left if it
// would overflow the canvas edge.
const flipLeft = hover.x > width - 160;
const chipX = flipLeft ? hover.x - 8 : hover.x + 8;
const anchor: "end" | "start" = flipLeft ? "end" : "start";
return (
<g style={{ pointerEvents: "none" }}>
<line
x1={hover.x}
x2={hover.x}
y1={PAD_TOP}
y2={PAD_TOP + innerH}
stroke={s.color}
strokeWidth={1}
strokeDasharray="2 3"
opacity={0.55}
/>
<text
x={chipX}
y={Math.max(14, hover.y - 10)}
textAnchor={anchor}
className="mono"
fontSize={10}
style={{ fill: "hsl(var(--surface-ink-3))" }}
>
{text}
</text>
<text
x={chipX}
y={Math.max(26, hover.y + 2)}
textAnchor={anchor}
className="display mono"
fontSize={16}
style={{ fill: "hsl(var(--surface-ink))" }}
>
{valueText}
</text>
</g>
);
})()
) : null}
</svg>
{/* Legend */}
<div className="flex items-center gap-4 mt-3 flex-wrap">
{series.map((s) => (
<div key={s.id} className="flex items-center gap-2">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-sm"
style={{ backgroundColor: s.color }}
/>
<span
className="mono text-[10.5px] uppercase tracking-[0.16em]"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{s.label}
</span>
</div>
))}
</div>
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
// ---------------------------------------------------------------------------
// HBarChart — horizontal bar chart for ranked lists (e.g. Top Providers).
//
// Each row gets a label slot on the left, a bar that grows from 0 to
// its value on mount, and a trailing value. Bars share a common scale
// derived from the max value. Designed for 48 rows; collapses to a
// list if rows are empty. Paper-friendly: cream background, dark ink.
// ---------------------------------------------------------------------------
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export interface HBarRow {
/** Stable id used as React key. */
id: string;
/** Left label (e.g. provider name). */
label: string;
/** Sub-label under the row (e.g. NPI). Optional. */
sublabel?: string;
/** Bar value. */
value: number;
/** Optional pre-formatted trailing value. Defaults to fmt.num(value). */
formatted?: string;
/** Optional color override. Defaults to --accent. */
color?: string;
/** Optional leading slot (icon, initials avatar, etc). */
leading?: React.ReactNode;
}
export interface HBarChartProps {
rows: HBarRow[];
/** Bar fill colour when a row doesn't override. Defaults to accent. */
defaultColor?: string;
/** Pixel height of each row. Default 44. */
rowHeight?: number;
/** Compact mode: smaller text, tighter padding. */
compact?: boolean;
/** Optional click handler. */
onRowClick?: (id: string) => void;
}
export function HBarChart({
rows,
defaultColor = "hsl(var(--accent))",
rowHeight = 44,
compact = false,
onRowClick,
}: HBarChartProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const [labelW, setLabelW] = useState(180);
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 800;
// Reserve ~38% of width for label + leading slot, capped between
// 140 and 260 so bars stay readable on wide and narrow layouts.
setLabelW(Math.max(140, Math.min(260, Math.floor(w * 0.38))));
});
ro.observe(el);
setLabelW(Math.max(140, Math.min(260, Math.floor(el.clientWidth * 0.38))));
return () => ro.disconnect();
}, []);
if (rows.length === 0) {
return (
<div
className="mono text-[11px] py-6 text-center"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
No data yet.
</div>
);
}
const max = Math.max(...rows.map((r) => r.value), 1);
return (
<div ref={wrapRef} className="w-full">
{rows.map((r, i) => {
const pct = (r.value / max) * 100;
const color = r.color ?? defaultColor;
return (
<div
key={r.id}
role={onRowClick ? "button" : undefined}
tabIndex={onRowClick ? 0 : undefined}
onClick={onRowClick ? () => onRowClick(r.id) : undefined}
onKeyDown={
onRowClick
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onRowClick(r.id);
}
}
: undefined
}
className={cn(
"group flex items-center gap-3",
compact ? "py-1.5" : "py-2.5",
onRowClick ? "cursor-pointer" : ""
)}
style={{ minHeight: rowHeight }}
>
{/* Leading slot (e.g. initials avatar) */}
{r.leading ? (
<div className="shrink-0">{r.leading}</div>
) : null}
{/* Label column */}
<div
className="shrink-0 min-w-0"
style={{ width: labelW - (r.leading ? 36 : 0) }}
>
<div
className={cn(
"truncate font-medium",
compact ? "text-[12.5px]" : "text-[13.5px]"
)}
style={{ color: "hsl(var(--surface-ink))" }}
>
{r.label}
</div>
{r.sublabel ? (
<div
className="mono truncate"
style={{
color: "hsl(var(--surface-ink-3))",
fontSize: 10.5,
marginTop: 1,
}}
>
{r.sublabel}
</div>
) : null}
</div>
{/* Bar */}
<div className="flex-1 min-w-0 relative">
{/* Track (hairline) */}
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
style={{ backgroundColor: "hsl(30 14% 14% / 0.08)" }}
aria-hidden
/>
<div
className="relative h-[10px] rounded-[2px]"
style={{
width: `${pct}%`,
backgroundColor: color,
opacity: 0.85,
animation: `bar-grow-h 700ms cubic-bezier(0.2,0.8,0.2,1) ${
i * 50
}ms both`,
transformOrigin: "left center",
boxShadow: `inset 0 1px 0 hsl(0 0% 100% / 0.15)`,
}}
aria-hidden
/>
{/* Rank tick on the bar */}
<span
className="mono absolute top-1/2 -translate-y-1/2 text-[9px] font-semibold tabular-nums"
style={{
left: 6,
color: "hsl(0 0% 100% / 0.92)",
mixBlendMode: "screen",
opacity: pct > 12 ? 1 : 0,
}}
>
{String(i + 1).padStart(2, "0")}
</span>
</div>
{/* Trailing value */}
<div
className={cn(
"shrink-0 text-right mono tabular-nums",
compact ? "text-[12.5px]" : "text-[14px]"
)}
style={{
color: "hsl(var(--surface-ink))",
minWidth: 56,
}}
>
{r.formatted ?? r.value.toLocaleString("en-US")}
</div>
</div>
);
})}
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
// ---------------------------------------------------------------------------
// SegmentedBar — thin horizontal status distribution strip.
//
// A single bar split into N color segments showing the share of each
// status. The legend below the bar shows the segment label + count +
// percentage, color-coded to the segment. Designed for 46 statuses.
// ---------------------------------------------------------------------------
import { fmt } from "@/lib/format";
export interface Segment {
id: string;
label: string;
count: number;
color: string;
}
export interface SegmentedBarProps {
segments: Segment[];
/** Optional caption shown above the bar (e.g. "Status distribution"). */
caption?: string;
}
export function SegmentedBar({ segments, caption }: SegmentedBarProps) {
const total = segments.reduce((s, x) => s + x.count, 0);
if (total <= 0) {
return (
<div
className="mono text-[11px] py-6 text-center"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
No claims yet.
</div>
);
}
return (
<div className="w-full">
{caption ? (
<div
className="mono text-[10.5px] uppercase tracking-[0.16em] mb-2"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{caption}
</div>
) : null}
{/* The bar */}
<div
className="flex w-full h-[10px] overflow-hidden rounded-[3px]"
style={{ boxShadow: "inset 0 0 0 1px hsl(30 14% 14% / 0.08)" }}
>
{segments.map((s, i) => {
const pct = (s.count / total) * 100;
if (pct <= 0) return null;
return (
<div
key={s.id}
className="h-full"
style={{
width: `${pct}%`,
backgroundColor: s.color,
animation: `bar-grow-h 700ms cubic-bezier(0.2,0.8,0.2,1) ${
i * 50
}ms both`,
transformOrigin: "left center",
}}
title={`${s.label}: ${s.count} (${((s.count / total) * 100).toFixed(0)}%)`}
aria-label={`${s.label}: ${s.count} of ${total}`}
/>
);
})}
</div>
{/* Legend */}
<ul className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5">
{segments.map((s) => {
const pct = (s.count / total) * 100;
if (s.count === 0) return null;
return (
<li
key={s.id}
className="flex items-center justify-between gap-2 text-[11.5px] min-w-0"
>
<div className="flex items-center gap-2 min-w-0">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-sm shrink-0"
style={{ backgroundColor: s.color }}
/>
<span
className="truncate"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
{s.label}
</span>
</div>
<div className="flex items-center gap-2 shrink-0 mono tabular-nums">
<span style={{ color: "hsl(var(--surface-ink))" }}>
{fmt.num(s.count)}
</span>
<span
style={{
color: "hsl(var(--surface-ink-3))",
fontSize: 10.5,
minWidth: 30,
textAlign: "right",
}}
>
{pct.toFixed(0)}%
</span>
</div>
</li>
);
})}
</ul>
</div>
);
}
+22 -9
View File
@@ -1,9 +1,19 @@
import type { ReactNode } from "react";
import { X } from "lucide-react";
interface Props {
eyebrow: string;
title: string;
onClose: () => void;
/**
* Optional slot for a right-side action (e.g. "Download 999" on the
* AckDrawer, "Download 837" on the ClaimDrawer added in SP21
* Phase 5 Task 5.2/5.10). Rendered to the left of the close
* button with a small visual gap. Anything goes a button, a
* status pill, an icon link. Default `null` so existing callers
* (ProviderDrawer) render unchanged.
*/
action?: ReactNode;
}
/**
@@ -15,7 +25,7 @@ interface Props {
* the app (see ``.eyebrow`` in ``src/index.css`` and the
* ``DrillDrawerHeader`` usage in ``ClaimDrawerHeader``).
*/
export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {
export function DrillDrawerHeader({ eyebrow, title, onClose, action }: Props) {
return (
<div className="flex items-center justify-between border-b border-border/30 px-6 py-4">
<div>
@@ -24,14 +34,17 @@ export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {
</div>
<h2 className="text-[18px] font-semibold tracking-tight mt-0.5">{title}</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close drawer"
className="rounded-md p-1 text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-4 w-4" aria-hidden />
</button>
<div className="flex items-center gap-2">
{action}
<button
type="button"
onClick={onClose}
aria-label="Close drawer"
className="rounded-md p-1 text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
</div>
);
}
@@ -0,0 +1,134 @@
interface Props {
/** The rule code (e.g. "R050_diagnosis_present" or just "R050"). */
rule: string;
}
interface RuleDoc {
/** Short human title (e.g. "Diagnosis pointer present"). */
title: string;
/** Plain-English description of what the rule checks. */
description: string;
/** Why the rule matters — operator-facing rationale. */
whyItMatters: string;
/** How to fix — short, actionable. */
howToFix: string;
}
/**
* SP21 Phase 5 Task 5.9: rule catalog used by ValidationRulePeekContent.
*
* The catalog is intentionally small it covers the rules we actually
* emit today (R050_diagnosis_present, R200_units_recommended). For
* anything not in the catalog the peek still renders (with an "Unknown
* rule" note) operators should still be able to open the peek for
* any rule code so they can see the originating message verbatim.
*
* Adding a new entry here is the source-of-truth for the rule's
* documentation. The ValidationPanel wires the peek by rule code; if
* we add new rules later (Phase 6+), add a new entry here.
*/
const RULE_CATALOG: Record<string, RuleDoc> = {
R050_diagnosis_present: {
title: "Diagnosis pointer present",
description:
"Each service line must point to at least one diagnosis code in the claim header (the HL segment's HI element). A missing pointer makes the line unprocessable on the payer side.",
whyItMatters:
"Payers reject claims with missing diagnosis pointers at the 999 stage, which would otherwise re-trigger the 999 rejection loop. Catching it here gives the operator a chance to attach the dx before submission.",
howToFix:
"Open the claim's Service Lines table and attach the relevant diagnosis code (e.g. E11.9) to the line. The pointer is the line's diagnosis pointer list.",
},
R200_units_recommended: {
title: "Service line units recommended",
description:
"Service lines that represent timed procedures (anesthesia, critical care, psychotherapy time-based codes) should carry an explicit units value. Defaulting to 1 is acceptable for most codes but flagged here for review.",
whyItMatters:
"Timed codes without units get under-reimbursed — payers default to 1 unit when the field is blank, even when the procedure took 45 minutes. The warning exists so an operator can verify the units are correct before submission.",
howToFix:
"Confirm the units value on the service line matches the documented encounter time. If the code is not time-based, no action is required.",
},
};
/**
* Peek body for a validation rule opens on top of the ClaimDrawer
* via PeekModal when the operator clicks a rule code in the
* ValidationPanel. The body shows the rule's title, description, why
* it matters, and how to fix it.
*
* Unknown rules (codes not in the catalog) render a small "Unknown
* rule see originating message" note rather than blowing up. The
* peek still renders so the operator can correlate the rule code to
* whatever they were just looking at.
*
* No fetch the catalog is static and bundled. (A future phase
* could swap this for a backend-served catalog if rules become
* user-extensible.)
*/
export function ValidationRulePeekContent({ rule }: Props) {
// Normalize: the rule code in the validation payload is the full
// form (`R050_diagnosis_present`), but a future backend response
// might use the short form (`R050`). Look up both.
const doc =
RULE_CATALOG[rule] ??
RULE_CATALOG[rule.split("_")[0] ?? ""] ??
null;
if (!doc) {
return (
<div className="space-y-2">
<div className="display text-[14px] text-foreground">
{rule}
</div>
<div
className="text-[12.5px]"
style={{ color: "hsl(var(--muted-foreground))" }}
>
Unknown rule. The originating message is the authoritative
description this peek is a no-op for undocumented rule codes.
</div>
</div>
);
}
return (
<div className="space-y-3">
<div className="display text-[15px] text-foreground">
{doc.title}
</div>
<div
className="mono text-[11px]"
style={{ color: "hsl(var(--muted-foreground))" }}
>
{rule}
</div>
<div className="text-[13px] leading-relaxed text-[color:var(--m-ink-primary)]">
{doc.description}
</div>
<div className="space-y-1.5 pt-1">
<Section heading="Why it matters">{doc.whyItMatters}</Section>
<Section heading="How to fix">{doc.howToFix}</Section>
</div>
</div>
);
}
function Section({
heading,
children,
}: {
heading: string;
children: React.ReactNode;
}) {
return (
<div>
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-0.5"
style={{ color: "hsl(var(--muted-foreground))" }}
>
{heading}
</div>
<div className="text-[12.5px] leading-relaxed text-[color:var(--m-ink-secondary)]">
{children}
</div>
</div>
);
}
+7 -2
View File
@@ -102,10 +102,15 @@ export function Lane({
<section
className="flex-1 min-w-[280px] flex flex-col rounded-md overflow-hidden"
style={{
background: "var(--tt-bg-elev)",
// "Lit from above" gradient — same treatment as the main
// .surface cards, in the inbox's cool/blue-grey hue family.
// Each lane becomes its own small lighter area against the
// surrounding ticker-tape background.
background:
"linear-gradient(180deg, hsl(220 18% 11.5%) 0%, hsl(220 18% 9.5%) 55%, hsl(220 18% 8.5%) 100%)",
border: "1px solid hsl(220 8% 18%)",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.03), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
"inset 0 1px 0 0 hsl(0 0% 100% / 0.05), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
}}
data-lane={name.toLowerCase()}
>
+89
View File
@@ -0,0 +1,89 @@
import { useQuery } from "@tanstack/react-query";
import { api, ApiError } from "@/lib/api";
import type { Ack } from "@/types";
/**
* UI-facing ack detail shape returned by `GET /api/acks/{id}`.
*
* Extends the base `Ack` shape with the `raw_999_text` field that
* `api.getAck` populates on top of the canonical row. The download
* button inside `AckDrawer` reads this string to hand the user the
* regenerated X12 file.
*/
export interface AckDetail extends Ack {
/**
* Full regenerated 999 X12 text. The backend re-emits the parsed
* transaction set so a user can grab the original file from the
* drawer without a second round-trip to `/api/acks/{id}/raw`.
*/
raw_999_text?: string;
/**
* Raw JSON envelope captured by the parser (the same dict the
* parser wrote into `raw_json`). Optional so older rows without
* it still typecheck.
*/
rawJson?: unknown;
}
/**
* Per-ack detail drawer query (AckDrawer · SP21 Phase 5 Task 5.2).
*
* Twin of `useProviderDetail` and `useClaimDetail` same return
* shape, same retry semantics, no in-memory fallback (the spec §5.2
* calls out that ACKs are backend-only; `useAcks` has no sample-data
* path so there's nothing to fall back on).
*
* Returns `{ data, isLoading, isError, error, refetch }`:
* - `ackId === null` (drawer closed): the query is disabled and the
* hook short-circuits to the empty drawer state so a closed drawer
* doesn't burn a network request or a TanStack cache slot.
* - `ackId` is set: fetches `GET /api/acks/{id}` via `api.getAck`.
* Cached 60 s the underlying ack rows don't change after
* parse-time.
* - On 404: the hook's retry predicate short-circuits (no retries)
* so the drawer's not-found state appears immediately rather than
* being masked by three back-to-back retry attempts.
*
* The drawer accepts `ackId: string | null` (the URL keeps the id as
* a string for clean deep-link round-tripping) and does the
* `Number()` coercion here, matching how `useProviderDetail`
* (`string`) and `useRemitDetail` (`string`) already work.
*/
export function useAckDetail(ackId: string | null): {
data: AckDetail | null;
isLoading: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
} {
const q = useQuery<AckDetail>({
queryKey: ["ack-detail", ackId],
queryFn: () => api.getAck(Number(ackId)),
enabled: ackId !== null,
staleTime: 60 * 1000,
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status === 404) return false;
return failureCount < 3;
},
});
if (ackId === null) {
return {
data: null,
isLoading: false,
isError: false,
error: null,
refetch: () => {},
};
}
return {
data: q.data ?? null,
isLoading: q.isLoading,
isError: q.isError,
error: q.error,
refetch: () => {
void q.refetch();
},
};
}
+219
View File
@@ -0,0 +1,219 @@
// @vitest-environment happy-dom
// Mirror the IS_REACT_ACT_ENVIRONMENT setup from useProviderDrawerUrlState.test.ts
// so React doesn't log act() warnings about the createRoot render/unmount
// and the popstate-driven state updates.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { useAckDrawerUrlState } from "./useAckDrawerUrlState";
/**
* Minimal renderHook shim same pattern as the other hook tests.
*/
function renderHook<TResult>(setup: () => TResult): {
result: { current: TResult | undefined };
unmount: () => void;
} {
const result: { current: TResult | undefined } = { current: undefined };
const container = document.createElement("div");
document.body.appendChild(container);
function Probe() {
result.current = setup();
return null;
}
const root: Root = createRoot(container);
act(() => {
root.render(React.createElement(Probe));
});
return {
result,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
/**
* Point happy-dom's URL at a known value. happy-dom v20 doesn't expose a
* writable `window.location.search`, but `window.happyDOM.setURL` updates
* the URL the window reports without triggering a navigation exactly
* what we want for mounting the hook at `/acks?ack=42` etc.
*/
function setLocation(url: string): void {
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url);
}
describe("useAckDrawerUrlState", () => {
type PushState = (state: unknown, unused: string, url?: string | URL | null) => void;
let pushStateMock: ReturnType<typeof vi.fn<PushState>>;
let replaceStateMock: ReturnType<typeof vi.fn<PushState>>;
beforeEach(() => {
pushStateMock = vi.fn();
replaceStateMock = vi.fn();
vi.stubGlobal("history", {
pushState: pushStateMock,
replaceState: replaceStateMock,
state: null,
});
});
afterEach(() => {
vi.unstubAllGlobals();
setLocation("http://localhost/");
});
it("reads the ?ack= param from window.location.search on mount", () => {
setLocation("http://localhost/acks?ack=42");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
expect(result.current?.ackId).toBe("42");
expect(typeof result.current?.open).toBe("function");
expect(typeof result.current?.close).toBe("function");
expect(typeof result.current?.setAckId).toBe("function");
unmount();
});
it("returns null ackId when no ?ack= param is set", () => {
setLocation("http://localhost/acks");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
expect(result.current?.ackId).toBeNull();
unmount();
});
it("returns null ackId when ?ack= is present but empty", () => {
setLocation("http://localhost/acks?ack=");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
expect(result.current?.ackId).toBeNull();
unmount();
});
it("open(id) pushes a new history entry containing ?ack=ID", () => {
setLocation("http://localhost/acks");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
act(() => {
result.current?.open("42");
});
expect(pushStateMock).toHaveBeenCalledTimes(1);
expect(replaceStateMock).not.toHaveBeenCalled();
const urlArg = pushStateMock.mock.calls[0][2] as string;
expect(urlArg).toContain("?ack=42");
unmount();
});
it("setAckId(id) replaces the current history entry (no new entry) and does NOT pushState", () => {
setLocation("http://localhost/acks?ack=42");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
act(() => {
result.current?.setAckId("43");
});
expect(replaceStateMock).toHaveBeenCalledTimes(1);
expect(pushStateMock).not.toHaveBeenCalled();
const urlArg = replaceStateMock.mock.calls[0][2] as string;
expect(urlArg).toContain("?ack=43");
unmount();
});
it("open() and close() preserve other query params (only ?ack= is touched)", () => {
setLocation("http://localhost/acks?sort=date&ack=42");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
act(() => {
result.current?.open("43");
});
const openUrl = pushStateMock.mock.calls[0][2] as string;
expect(openUrl).toContain("sort=date");
expect(openUrl).toMatch(/[?&]ack=43/);
act(() => {
result.current?.close();
});
const closeUrl = pushStateMock.mock.calls[1][2] as string;
expect(closeUrl).toContain("sort=date");
expect(closeUrl).not.toContain("ack=");
unmount();
});
it("close() pushes a new history entry with the ?ack= param stripped", () => {
setLocation("http://localhost/acks?ack=42");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
act(() => {
result.current?.close();
});
expect(pushStateMock).toHaveBeenCalledTimes(1);
expect(replaceStateMock).not.toHaveBeenCalled();
const urlArg = pushStateMock.mock.calls[0][2] as string;
expect(urlArg).not.toContain("?ack=");
expect(result.current?.ackId).toBeNull();
unmount();
});
it("updates ackId in response to popstate (browser back/forward)", async () => {
setLocation("http://localhost/acks?ack=42");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
expect(result.current?.ackId).toBe("42");
setLocation("http://localhost/acks?ack=43");
await act(async () => {
window.dispatchEvent(new PopStateEvent("popstate"));
await Promise.resolve();
});
expect(result.current?.ackId).toBe("43");
unmount();
});
it("does not collide with the existing ?claim=, ?remit=, or ?provider= params (orthogonal keys)", () => {
// The acks drawer is independent of the claim/remit/provider
// drawers — opening an ack must leave the others intact so a user
// can deep-link to multiple states simultaneously (though the UI
// currently only shows one drawer at a time, the URL params don't
// know that).
setLocation("http://localhost/?claim=CLM-1&remit=REM-1&provider=1881068062");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
act(() => {
result.current?.open("42");
});
const openUrl = pushStateMock.mock.calls[0][2] as string;
expect(openUrl).toContain("claim=CLM-1");
expect(openUrl).toContain("remit=REM-1");
expect(openUrl).toContain("provider=1881068062");
expect(openUrl).toMatch(/[?&]ack=42/);
unmount();
});
});
+105
View File
@@ -0,0 +1,105 @@
import { useCallback, useEffect, useState } from "react";
/**
* Read the current `?ack=…` query param off `window.location.search`.
* Returns `null` when the param is absent or empty.
*
* `URLSearchParams` is the standard, locale-free way to parse query
* strings in the browser. Using it (rather than hand-rolled string
* slicing) means we correctly handle multiple params and percent-encoded
* characters in ack ids without surprises.
*
* Param name is `?ack=` chosen to mirror the existing `?provider=…`,
* `?claim=…`, and `?remit=…` drilldown conventions (one-word token,
* alphabetical brevity, no collision with the existing `Acks` table
* columns). The id value is treated as a string in the URL so deep
* links (`/acks?ack=42`) round-trip identically across the app, even
* though the backend `Ack.id` is a numeric `AckDrawer` does the
* `Number()` coercion when calling `api.getAck`.
*/
function readAckId(): string | null {
const params = new URLSearchParams(window.location.search);
const value = params.get("ack");
return value === "" ? null : value;
}
/**
* Build the URL we want to push/replace into history.
*
* - `ackId === null` drop the `?ack=` param, preserving any
* other params (e.g. `?page=2&ack=…` keeps `page=2`).
* - `ackId !== null` set the param to the new id, also preserving
* any other params.
*
* We return `pathname + search + hash` (a relative URL) rather than the
* full href `history.pushState` accepts a relative URL and rewriting
* only the relative form keeps the document's origin stable.
*/
function buildUrl(ackId: string | null): string {
const url = new URL(window.location.href);
if (ackId === null) {
url.searchParams.delete("ack");
} else {
url.searchParams.set("ack", ackId);
}
return url.pathname + url.search + url.hash;
}
/**
* Per-ack detail drawer URL state (AckDrawer).
*
* Mirrors `useProviderDrawerUrlState` / `useRemitDrawerUrlState` but
* for the 999-ACK drawer reads `?ack=` from the URL on mount and
* keeps the value in sync with history as the drawer is opened and
* closed.
*
* - `ackId`: the id parsed from the URL (or `null` when the param
* is absent). React state so consumers re-render on changes.
* - `open(id)`: pushes a NEW history entry with `?ack={id}` so the
* browser Back button returns to the previous page (e.g. the
* acks list) and not just to the previously-open ack.
* - `setAckId(id)`: REPLACES the current history entry kept for
* symmetry with the other drawer hooks so a future j/k nav
* handler doesn't have to special-case ack ids.
* - `close()`: pushes a NEW entry that strips the param, so Back
* from the closed drawer returns to whatever page the user was
* on before opening the drawer.
*
* The hook subscribes to `popstate` so that browser Back/Forward
* (which fire popstate rather than our own pushState) propagate into
* the React state. Without this, hitting Back would change the URL
* but leave the drawer open on the stale id.
*/
export function useAckDrawerUrlState(): {
ackId: string | null;
open: (id: string) => void;
close: () => void;
setAckId: (id: string) => void;
} {
const [ackId, setAckIdState] = useState<string | null>(() => readAckId());
const open = useCallback((id: string) => {
window.history.pushState(null, "", buildUrl(id));
setAckIdState(id);
}, []);
const setAckId = useCallback((id: string) => {
window.history.replaceState(null, "", buildUrl(id));
setAckIdState(id);
}, []);
const close = useCallback(() => {
window.history.pushState(null, "", buildUrl(null));
setAckIdState(null);
}, []);
useEffect(() => {
const onPopState = () => {
setAckIdState(readAckId());
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
return { ackId, open, close, setAckId };
}
+288
View File
@@ -0,0 +1,288 @@
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
import React, { act } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { createRoot, type Root } from "react-dom/client";
// Mock the api + download + toast surfaces so the hook can be exercised
// in isolation. Each test sets up its own expectations on these.
vi.mock("@/lib/api", () => ({
api: {
exportBatch837: vi.fn(),
},
}));
vi.mock("@/lib/download", () => ({
downloadBlob: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
},
}));
import { useBatchExport, type BatchExportItem } from "./useBatchExport";
import { api } from "@/lib/api";
import { downloadBlob } from "@/lib/download";
import { toast } from "sonner";
const mockExportBatch837 = vi.mocked(api.exportBatch837);
const mockDownloadBlob = vi.mocked(downloadBlob);
const mockToastSuccess = vi.mocked(toast.success);
const mockToastWarning = vi.mocked(toast.warning);
const mockToastError = vi.mocked(toast.error);
function makeItem(claimId: string): BatchExportItem {
return { kind: "837p", data: { claim_id: claimId } };
}
function make835Item(id: string): BatchExportItem {
return { kind: "835", data: { payer_claim_control_number: id } };
}
/**
* Render the hook with a fixed initial list and capture its return
* value. The `rerenderWith` helper re-creates the harness with a new
* initial list that's how we simulate "new claims streamed in"
* without poking at internal useState setters from outside the
* component.
*/
function setup(initial: BatchExportItem[]) {
let latest: ReturnType<typeof useBatchExport> | null = null;
const container = document.createElement("div");
document.body.append(container);
function Harness({ items }: { items: BatchExportItem[] }) {
latest = useBatchExport(items);
return null;
}
const root: Root = createRoot(container);
function render(items: BatchExportItem[]) {
act(() => {
root.render(React.createElement(Harness, { items }));
});
}
render(initial);
return {
get latest(): ReturnType<typeof useBatchExport> {
if (!latest) throw new Error("hook result not yet captured");
return latest;
},
/** Re-render with a new items list (e.g. to simulate streaming). */
rerenderWith(items: BatchExportItem[]) {
render(items);
},
unmount() {
act(() => root.unmount());
container.remove();
},
};
}
describe("useBatchExport", () => {
beforeEach(() => {
mockExportBatch837.mockReset();
mockDownloadBlob.mockReset();
mockToastSuccess.mockReset();
mockToastWarning.mockReset();
mockToastError.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("auto-selects all 837P claims passed in initially", () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2"), makeItem("CLM-3")]);
expect(h.latest.selectedClaimIds.size).toBe(3);
expect(h.latest.total837InStream).toBe(3);
h.unmount();
});
it("ignores 835 items when counting / selecting", () => {
const h = setup([makeItem("CLM-1"), make835Item("PCN-1")]);
expect(h.latest.selectedClaimIds.size).toBe(1);
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(true);
expect(h.latest.total837InStream).toBe(1);
h.unmount();
});
it("auto-selects a new 837P claim that streams in", () => {
const h = setup([makeItem("CLM-1")]);
expect(h.latest.selectedClaimIds.size).toBe(1);
h.rerenderWith([makeItem("CLM-1"), makeItem("CLM-2")]);
expect(h.latest.selectedClaimIds.size).toBe(2);
expect(h.latest.selectedClaimIds.has("CLM-2")).toBe(true);
h.unmount();
});
it("does not deselect existing claims when a new one streams in", () => {
const h = setup([makeItem("CLM-1")]);
act(() => {
h.latest.onToggleSelect("CLM-1"); // deselect
});
expect(h.latest.selectedClaimIds.size).toBe(0);
h.rerenderWith([makeItem("CLM-1"), makeItem("CLM-2")]);
// CLM-1 is still deselected (user's choice is preserved), CLM-2
// is auto-selected.
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(false);
expect(h.latest.selectedClaimIds.has("CLM-2")).toBe(true);
h.unmount();
});
it("onToggleSelect adds and removes", () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
act(() => {
h.latest.onToggleSelect("CLM-1"); // toggle off
});
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(false);
act(() => {
h.latest.onToggleSelect("CLM-1"); // toggle back on
});
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(true);
h.unmount();
});
it("onToggleAll deselects when everything is selected", () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
expect(h.latest.selectedClaimIds.size).toBe(2);
act(() => {
h.latest.onToggleAll();
});
expect(h.latest.selectedClaimIds.size).toBe(0);
h.unmount();
});
it("onToggleAll selects everything when partial / none are selected", () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2"), makeItem("CLM-3")]);
act(() => {
h.latest.onToggleSelect("CLM-1"); // partial: only CLM-2 and CLM-3
});
act(() => {
h.latest.onToggleSelect("CLM-2");
});
act(() => {
h.latest.onToggleAll();
});
expect(h.latest.selectedClaimIds.size).toBe(3);
h.unmount();
});
it("onExport does nothing when no batch id is recorded", async () => {
const h = setup([makeItem("CLM-1")]);
await h.latest.onExport();
expect(mockExportBatch837).not.toHaveBeenCalled();
expect(mockToastError).toHaveBeenCalled();
h.unmount();
});
it("onExport does nothing when selection is empty (after recordBatchId)", async () => {
const h = setup([makeItem("CLM-1")]);
h.latest.recordBatchId("server-uuid-123");
act(() => {
h.latest.onToggleSelect("CLM-1"); // deselect
});
await h.latest.onExport();
expect(mockExportBatch837).not.toHaveBeenCalled();
h.unmount();
});
it("onExport calls api.exportBatch837 with the recorded batch id and selected ids, then downloadBlob + success toast", async () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
h.latest.recordBatchId("server-uuid-456");
const exportedBlob = new Blob([new Uint8Array([1, 2, 3])], {
type: "application/zip",
});
mockExportBatch837.mockResolvedValueOnce({
blob: exportedBlob,
filename: "batch-server-uuid-456-2-claims.zip",
serializeErrors: [],
});
await h.latest.onExport();
expect(mockExportBatch837).toHaveBeenCalledWith(
"server-uuid-456",
expect.arrayContaining(["CLM-1", "CLM-2"]),
);
expect(mockDownloadBlob).toHaveBeenCalledTimes(1);
// Regression: downloadBlob's signature is (filename, blob). An earlier
// build had the args swapped, which made URL.createObjectURL receive
// the filename string and throw "Overload resolution failed" — pin
// the order here so a swap can't sneak back in.
expect(mockDownloadBlob).toHaveBeenCalledWith(
"batch-server-uuid-456-2-claims.zip",
exportedBlob,
);
expect(mockToastSuccess).toHaveBeenCalledWith(
"Exported 2 claims as X12",
expect.objectContaining({ description: "batch-server-uuid-456-2-claims.zip" }),
);
expect(mockToastWarning).not.toHaveBeenCalled();
h.unmount();
});
it("onExport shows a warning toast with serialize-error details on partial failure", async () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
h.latest.recordBatchId("server-uuid-789");
mockExportBatch837.mockResolvedValueOnce({
blob: new Blob([new Uint8Array([1])]),
filename: "batch-server-uuid-789-1-claims.zip",
serializeErrors: [{ claim_id: "CLM-2", reason: "no raw_json" }],
});
await h.latest.onExport();
expect(mockDownloadBlob).toHaveBeenCalledTimes(1);
expect(mockToastSuccess).not.toHaveBeenCalled();
expect(mockToastWarning).toHaveBeenCalledWith(
"Exported 1 · 1 couldn't be regenerated",
expect.objectContaining({
description: expect.stringContaining("CLM-2: no raw_json"),
}),
);
h.unmount();
});
it("onExport shows an error toast and resets exporting=false on network failure", async () => {
const h = setup([makeItem("CLM-1")]);
h.latest.recordBatchId("server-uuid-abc");
mockExportBatch837.mockRejectedValueOnce(new Error("network down"));
await h.latest.onExport();
expect(mockToastError).toHaveBeenCalledWith("network down");
expect(h.latest.exporting).toBe(false);
h.unmount();
});
it("exporting is true while the export is in flight, false after", async () => {
const h = setup([makeItem("CLM-1")]);
h.latest.recordBatchId("server-uuid-xyz");
let resolveExport: (v: unknown) => void = () => {};
mockExportBatch837.mockReturnValueOnce(
new Promise((res) => {
resolveExport = res;
}) as ReturnType<typeof api.exportBatch837>,
);
const exportPromise = h.latest.onExport();
// Yield once so the exporting=true state has flushed.
await act(async () => {
await Promise.resolve();
});
expect(h.latest.exporting).toBe(true);
resolveExport({
blob: new Blob(),
filename: "x.zip",
serializeErrors: [],
});
// The state update in onExport's `finally` block happens on a
// microtask after the promise resolves; wrapping the await in
// act() flushes it.
await act(async () => {
await exportPromise;
});
expect(h.latest.exporting).toBe(false);
h.unmount();
});
});
+174
View File
@@ -0,0 +1,174 @@
// useBatchExport — owns the per-claim selection state and the
// "click Export → POST → download ZIP" flow for the Upload page's
// streaming view.
//
// Extracted from Upload.tsx in SP9 (June 2026) so the logic is unit-
// testable in isolation from the page (and so the page itself stays
// focused on layout). All state is local to the hook — selection is
// not persisted across navigations, and the captured `batchId` is the
// most-recently-completed parse's server-side id.
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { downloadBlob } from "@/lib/download";
/** Minimal shape of a streamed claim this hook needs to read the id. */
export interface BatchExportItem {
kind: "837p" | "835";
data: { claim_id?: string; payer_claim_control_number?: string };
}
export interface UseBatchExportResult {
/** Currently-selected 837P claim ids. */
selectedClaimIds: Set<string>;
/** True while an export POST is in flight. */
exporting: boolean;
/** Total 837P claims currently in the stream. */
total837InStream: number;
/** Toggle a single claim's selection. */
onToggleSelect: (claimId: string) => void;
/** Toggle between "all selected" and "none selected" across the stream. */
onToggleAll: () => void;
/** Fire the export. Returns the promise so tests can await it. */
onExport: () => Promise<void>;
/**
* Called by the parent after a successful parse to record the
* server-side batch id. Captured into a ref so a re-parse that
* completes mid-export can't swap in a different id under the
* export promise.
*/
recordBatchId: (batchId: string | null) => void;
}
export function useBatchExport(items: BatchExportItem[]): UseBatchExportResult {
const [selectedClaimIds, setSelectedClaimIds] = useState<Set<string>>(
() => new Set(),
);
const [exporting, setExporting] = useState(false);
const lastBatchIdRef = useRef<string | null>(null);
// Tracks every claim id that has ever been in the stream. Used by the
// auto-select effect to distinguish "new claim, default-select it"
// from "user previously deselected this claim — leave it alone".
// Without this, a re-render with the same items list would re-add
// every claim that the user explicitly deselected.
const seenClaimIdsRef = useRef<Set<string>>(new Set());
// Auto-select newly streamed 837P claims.
//
// Pre-compute the new claim ids OUTSIDE the setState callback so
// the ref is mutated exactly once per items.length change and the
// setState callback only runs when there's actually something new
// to add. The previous inline pattern (mutating the ref inside the
// setState callback) failed under React StrictMode's effect
// double-invoke: the second invoke saw an empty prev (the first
// invoke's queued update was dropped) AND a ref that already
// contained the ids, so the second invoke added nothing — and the
// selection ended up empty even though the stream populated.
useEffect(() => {
const newIds: string[] = [];
for (const item of items) {
if (item.kind === "837p" && item.data.claim_id) {
const id = item.data.claim_id;
// Only add if we've never seen this id before (so a
// re-render with the same items doesn't re-add anything the
// user just deselected) AND it's not already selected (no
// need to add it again).
if (!seenClaimIdsRef.current.has(id)) {
seenClaimIdsRef.current.add(id);
newIds.push(id);
}
}
}
if (newIds.length === 0) return;
setSelectedClaimIds((prev) => {
const next = new Set(prev);
for (const id of newIds) next.add(id);
return next;
});
// Key on items.length so a re-render with the same set of items
// (just new array identity) doesn't re-run the effect.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items.length]);
const total837InStream = useMemo(
() => items.filter((i) => i.kind === "837p").length,
[items],
);
function onToggleSelect(claimId: string) {
setSelectedClaimIds((prev) => {
const next = new Set(prev);
if (next.has(claimId)) {
next.delete(claimId);
} else {
next.add(claimId);
}
return next;
});
}
function onToggleAll() {
const streamed837Ids = items
.filter((i): i is BatchExportItem & { data: { claim_id: string } } =>
i.kind === "837p" && !!i.data.claim_id,
)
.map((i) => i.data.claim_id);
if (
streamed837Ids.length > 0 &&
streamed837Ids.every((id) => selectedClaimIds.has(id))
) {
setSelectedClaimIds(new Set());
} else {
setSelectedClaimIds(new Set(streamed837Ids));
}
}
function recordBatchId(batchId: string | null) {
lastBatchIdRef.current = batchId;
}
async function onExport() {
const batchId = lastBatchIdRef.current;
if (!batchId) {
toast.error("No batch to export — parse a file first.");
return;
}
const ids = Array.from(selectedClaimIds);
if (ids.length === 0) return;
setExporting(true);
try {
const { blob, filename, serializeErrors } =
await api.exportBatch837(batchId, ids);
downloadBlob(filename, blob);
if (serializeErrors.length === 0) {
toast.success(`Exported ${ids.length} claims as X12`, {
description: filename,
});
} else {
toast.warning(
`Exported ${ids.length - serializeErrors.length} · ${serializeErrors.length} couldn't be regenerated`,
{
description: serializeErrors
.map((e) => `${e.claim_id}: ${e.reason}`)
.join("\n"),
},
);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
setExporting(false);
}
}
return {
selectedClaimIds,
exporting,
total837InStream,
onToggleSelect,
onToggleAll,
onExport,
recordBatchId,
};
}
+48 -17
View File
@@ -127,36 +127,49 @@
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
/* Subtle "lit from above" wash gives every page a slightly
lighter zone in the upper third without lifting the base
color. A vertical gradient + a soft top-center halo, both
very low opacity. The composition is additive: a soft glow
behind the page header, fading to nothing well before the
fold. Pointer-events unaffected (it's a background). */
background-image:
radial-gradient(ellipse 90% 38% at 50% -6%, hsla(220, 28%, 26%, 0.22), transparent 62%),
linear-gradient(180deg, hsla(220, 22%, 14%, 0.30) 0%, hsla(220, 22%, 8%, 0.10) 22%, transparent 42%);
background-attachment: fixed;
}
/* A single, precise light source anchored top-right.
One light, not two an instrument, not a wash. */
/* A precise top-right accent + a complementary soft top-center
softbox. Two light sources, both very subtle the result reads
as "the chrome is being lit from above" rather than "there's a
gradient in the background". The softbox adds the small lift
the cards sit in; the accent preserves the instrument character. */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background: radial-gradient(
55rem 35rem at 100% -15%,
hsla(212, 100%, 60%, 0.07),
transparent 62%
);
background:
radial-gradient(ellipse 70% 38% at 50% 0%, hsla(220, 32%, 28%, 0.16), transparent 65%),
radial-gradient(55rem 35rem at 100% -15%, hsla(212, 100%, 60%, 0.09), transparent 62%);
}
/* Hairline grid the substrate of a precision instrument.
Sits behind everything, gives the dark surfaces a sense of
scale and the work a sense of "field". */
scale and the work a sense of "field". Slightly more present
than before so the texture actually reads against the softer
top wash. */
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.5;
opacity: 0.6;
background-image:
linear-gradient(to right, hsl(222 10% 16% / 0.18) 1px, transparent 1px),
linear-gradient(to bottom, hsl(222 10% 16% / 0.18) 1px, transparent 1px);
linear-gradient(to right, hsl(222 10% 18% / 0.20) 1px, transparent 1px),
linear-gradient(to bottom, hsl(222 10% 18% / 0.20) 1px, transparent 1px);
background-size: 64px 64px;
mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, black 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, black 30%, transparent 80%);
@@ -226,22 +239,40 @@
}
/* Soft card surface a 1px hairline + a 1px inner highlight to
give the surface a sense of depth without a heavy shadow. */
give the surface a sense of depth without a heavy shadow.
The vertical gradient (lighter at top, darker at bottom) plus
a slightly more present inner highlight makes the card read
as a subtle "lit from above" panel each card becomes its
own small lighter area without lifting the base card color. */
.surface {
background-color: hsl(var(--card));
background: linear-gradient(
180deg,
hsl(222 16% 10.5%) 0%,
hsl(222 16% 8.5%) 55%,
hsl(222 16% 7.5%) 100%
);
border: 1px solid hsl(var(--border));
box-shadow:
inset 0 1px 0 0 hsl(0 0% 100% / 0.04),
inset 0 1px 0 0 hsl(0 0% 100% / 0.06),
0 1px 2px 0 hsl(0 0% 0% / 0.4);
}
/* Subtle elevated surface used for cards that should lift a
little more than the default. */
little more than the default. Same "lit from above" treatment
as .surface, with a touch more lift in the gradient stops and
a more visible inner highlight. The card body becomes a
slightly more present lighter area than the surrounding
chrome. */
.surface-2 {
background: linear-gradient(180deg, hsl(222 16% 9%) 0%, hsl(222 16% 7.5%) 100%);
background: linear-gradient(
180deg,
hsl(222 16% 11%) 0%,
hsl(222 16% 9%) 55%,
hsl(222 16% 7.5%) 100%
);
border: 1px solid hsl(var(--border));
box-shadow:
inset 0 1px 0 0 hsl(0 0% 100% / 0.05),
inset 0 1px 0 0 hsl(0 0% 100% / 0.08),
0 1px 3px 0 hsl(0 0% 0% / 0.5);
}
+135
View File
@@ -217,4 +217,139 @@ describe("api GET helpers", () => {
);
await expect(api.serializeClaim837("ghost")).rejects.toBeInstanceOf(ApiError);
});
it("exportBatch837 returns the blob and filename from a 200 ZIP response", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // ZIP magic
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition":
'attachment; filename="batch-abc123-3-claims.zip"',
},
})
);
const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
expect(out.filename).toBe("batch-abc123-3-claims.zip");
expect(out.blob).toBeInstanceOf(Blob);
// POST to the batch-scoped endpoint with claim_ids in the body.
const called = mockFetch.mock.calls[0][0] as string;
expect(called).toContain("/api/batches/abc123/export-837");
const init = mockFetch.mock.calls[0][1] as RequestInit;
expect(init.method).toBe("POST");
const body = JSON.parse(init.body as string);
expect(body).toEqual({ claim_ids: ["CLM-1", "CLM-2", "CLM-3"] });
});
it("exportBatch837 parses the X-Cyclone-Serialize-Errors header into serializeErrors", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
const errs = [
{ claim_id: "CLM-2", reason: "no raw_json" },
{ claim_id: "CLM-3", reason: "raw_json invalid: ..." },
];
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition":
'attachment; filename="batch-abc123-1-claims.zip"',
"x-cyclone-serialize-errors": JSON.stringify(errs),
},
})
);
const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
expect(out.serializeErrors).toEqual(errs);
});
it("exportBatch837 returns an empty serializeErrors array when the header is missing", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition": 'attachment; filename="batch-x-1-claims.zip"',
},
})
);
const out = await api.exportBatch837("x", ["CLM-1"]);
expect(out.serializeErrors).toEqual([]);
});
it("exportBatch837 falls back to a default filename when Content-Disposition is missing", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: { "content-type": "application/zip" },
})
);
const out = await api.exportBatch837("xyz", ["CLM-1"]);
expect(out.filename).toBe("batch-xyz-claims.zip");
});
it("exportBatch837 throws ApiError on 404", async () => {
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({ error: "Not found", detail: "unknown batch: ghost" }),
{ status: 404, headers: { "content-type": "application/json" } }
)
);
await expect(
api.exportBatch837("ghost", ["CLM-1"])
).rejects.toBeInstanceOf(ApiError);
});
it("exportBatch837 throws on empty claim_ids without making a request", async () => {
await expect(api.exportBatch837("any", [])).rejects.toThrow();
expect(mockFetch).not.toHaveBeenCalled();
});
it("getBatchDiff surfaces a clear ApiError when a backend 400 wraps detail in a nested object", async () => {
// The backend can wrap structured errors as
// `{"detail": {"error": "Missing param", "detail": "..."}}`. The
// client must walk into the nested `detail` so the user sees the
// human-readable message rather than a blob of raw JSON.
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({
detail: {
error: "Missing param",
detail: "Both ?a=<batch_id> and ?b=<batch_id> are required.",
},
}),
{ status: 400, headers: { "content-type": "application/json" } }
)
);
let caught: unknown;
try {
await api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "930f25e07eab4ac0908c9770689385e1");
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(ApiError);
expect((caught as ApiError).status).toBe(400);
// The unwrapped message must surface — not the raw JSON.
expect((caught as ApiError).message).toBe(
"Both ?a=<batch_id> and ?b=<batch_id> are required."
);
});
it("getBatchDiff throws ApiError(400) before hitting the network if either id is empty", async () => {
// Defense-in-depth: the hook's `enabled` guard should prevent this
// from ever firing, but if it ever does, the client must refuse to
// make a malformed request and surface a clear local error.
await expect(api.getBatchDiff("", "930f25e07eab4ac0908c9770689385e1")).rejects.toMatchObject({
status: 400,
message: expect.stringContaining("?a=<batch_id>"),
});
await expect(api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "")).rejects.toMatchObject({
status: 400,
message: expect.stringContaining("?b=<batch_id>"),
});
// Network must not be touched.
expect(mockFetch).not.toHaveBeenCalled();
});
});
+96 -5
View File
@@ -175,12 +175,24 @@ async function readErrorBody(res: Response): Promise<string> {
try {
const t = await res.text();
if (!t) return "";
// FastAPI errors are `{ "error": "...", "detail": "..." }`. Surface the
// detail if present, else the raw text.
// FastAPI errors can be flat `{ "error": "...", "detail": "..." }`
// or wrapped as `{ "detail": { "error": "...", "detail": "..." } }`
// (the wrapper our app uses for structured errors). Walk into nested
// `detail` objects until we hit a string field — guards against the
// raw JSON leaking into the user-visible error message.
try {
const obj = JSON.parse(t) as { detail?: unknown; error?: unknown };
if (typeof obj.detail === "string") return obj.detail;
if (typeof obj.error === "string") return obj.error;
let current: unknown = JSON.parse(t);
for (let depth = 0; depth < 5; depth++) {
if (!current || typeof current !== "object") break;
const obj = current as { detail?: unknown; error?: unknown };
if (typeof obj.detail === "string") return obj.detail;
if (typeof obj.error === "string") return obj.error;
if (obj.detail && typeof obj.detail === "object") {
current = obj.detail;
continue;
}
break;
}
} catch {
// not JSON; fall through
}
@@ -558,6 +570,16 @@ async function getRemittance<T = unknown>(id: string): Promise<T> {
*/
async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
if (!isConfigured) throw notConfiguredError();
// Defense-in-depth: even though the `useBatchDiff` hook gates on both
// ids being non-empty, refuse to fire the request if either is
// missing. Surfaces a clear local error instead of letting the
// backend return its generic "Missing param" 400.
if (typeof a !== "string" || a.length === 0) {
throw new ApiError(400, "Missing param: ?a=<batch_id> is required.");
}
if (typeof b !== "string" || b.length === 0) {
throw new ApiError(400, "Missing param: ?b=<batch_id> is required.");
}
const res = await fetch(
joinUrl(
`/api/batch-diff?${qs({ a, b })}`,
@@ -786,6 +808,74 @@ async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
return { ...mapAck(row), rawJson: row.raw_json };
}
/**
* Download a ZIP of regenerated X12 837 files for a parsed batch.
*
* Drives `POST /api/batches/{batchId}/export-837` with the requested
* claim_ids in the body. The backend returns a binary ZIP whose
* entries follow the HCPF X12 File Naming Standards template
* ``{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (one file per
* successfully serialized claim, with a per-claim millisecond offset
* so every entry has a unique 17-digit timestamp). The ZIP itself is
* named ``batch-{batchId}-{N}-claims.zip`` via Content-Disposition,
* where N is the success count.
*
* Per-claim serialization failures are surfaced via the
* `X-Cyclone-Serialize-Errors` response header (JSON-encoded array of
* `{claim_id, reason}`). The ZIP still contains the successful claims;
* the failures are returned alongside so the UI can show a partial-
* success toast like "Exported 18 · 2 couldn't be regenerated".
*
* Throws `ApiError` on non-2xx 404 (batch missing) is the most
* likely case. The client-side guard rejects empty `claimIds` without
* making a request.
*/
export interface BatchExportResult {
blob: Blob;
filename: string;
serializeErrors: Array<{ claim_id: string; reason: string }>;
}
async function exportBatch837(
batchId: string,
claimIds: string[],
): Promise<BatchExportResult> {
if (!isConfigured) throw notConfiguredError();
if (claimIds.length === 0) {
throw new Error("claimIds is empty");
}
const res = await fetch(
joinUrl(`/api/batches/${encodeURIComponent(batchId)}/export-837`),
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/zip",
},
body: JSON.stringify({ claim_ids: claimIds }),
},
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
const blob = await res.blob();
const cd = res.headers.get("content-disposition") ?? "";
const match = /filename="?([^";]+)"?/i.exec(cd);
const filename = match?.[1] ?? `batch-${batchId}-claims.zip`;
const errHeader = res.headers.get("x-cyclone-serialize-errors");
let serializeErrors: Array<{ claim_id: string; reason: string }> = [];
if (errHeader) {
try {
serializeErrors = JSON.parse(errHeader);
} catch {
// Malformed header — treat as empty rather than failing the download.
serializeErrors = [];
}
}
return { blob, filename, serializeErrors };
}
export const api = {
isConfigured,
baseUrl: BASE_URL,
@@ -799,6 +889,7 @@ export const api = {
listClaims,
getClaimDetail,
serializeClaim837,
exportBatch837,
listRemittances,
getRemittance,
listProviders,
+126 -7
View File
@@ -9,13 +9,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Acks } from "./Acks";
import { api } from "@/lib/api";
vi.mock("@/lib/api", () => ({
api: {
isConfigured: true,
listAcks: vi.fn(),
getAck: vi.fn(),
},
}));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
api: {
isConfigured: true,
listAcks: vi.fn(),
getAck: vi.fn(),
},
};
});
function renderIntoContainer(element: React.ReactElement): {
container: HTMLDivElement;
@@ -105,6 +109,10 @@ function hasExactlyOneSelectedRow(): boolean {
describe("Acks", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset URL state between tests so a previous `?ack=` doesn't leak.
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
"http://localhost/acks"
);
});
it("renders a single ack row with counts and ack code", async () => {
@@ -448,4 +456,115 @@ describe("Acks", () => {
unmount();
});
it("test_clicking_a_row_opens_the_ack_drawer", async () => {
// SP21 Phase 5 Task 5.3: clicking an acks row drills into the
// matching ack via `?ack=ID` URL state. The AckDrawer mounts
// but the actual content depends on `useAckDetail` — we don't
// need to verify drawer internals here, just that the URL got
// pushed and the drawer portal opens.
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "P",
parsedAt: "2026-06-20T12:00:00Z",
},
],
total: 1,
returned: 1,
has_more: false,
});
// Stub the per-ack fetch so `useAckDetail` resolves cleanly
// (avoids TanStack Query's "Query data cannot be undefined"
// warning). We only assert on the drawer's presence, so the
// shape doesn't need to be precise.
(api.getAck as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "P",
parsedAt: "2026-06-20T12:00:00Z",
raw_999_text: "ISA*~\n",
});
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-uuid-1");
// No drawer yet.
expect(
document.body.querySelector('[data-testid="ack-drawer"]')
).toBeNull();
// Click the row.
const row = rowAt(0);
expect(row).not.toBeNull();
await act(async () => {
row!.click();
await Promise.resolve();
});
await settle(
() => document.body.querySelector('[data-testid="ack-drawer"]') !== null
);
// The drawer is now in the DOM.
expect(
document.body.querySelector('[data-testid="ack-drawer"]')
).not.toBeNull();
unmount();
});
it("test_deep_link_with_ack_param_opens_drawer_on_mount", async () => {
// /acks?ack=42 deep link → drawer opens on mount without a click.
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "P",
parsedAt: "2026-06-20T12:00:00Z",
},
],
total: 1,
returned: 1,
has_more: false,
});
(api.getAck as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "P",
parsedAt: "2026-06-20T12:00:00Z",
raw_999_text: "ISA*~\n",
});
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
"http://localhost/acks?ack=42"
);
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-uuid-1");
await settle(
() => document.body.querySelector('[data-testid="ack-drawer"]') !== null
);
// The drawer is in the DOM on first render.
expect(
document.body.querySelector('[data-testid="ack-drawer"]')
).not.toBeNull();
unmount();
});
});

Some files were not shown because too many files have changed in this diff Show More