Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fc3d9adda | |||
| 35e0f5a422 | |||
| a25504bd3a | |||
| 70280f70bb | |||
| a933672411 | |||
| ca40fd72c3 | |||
| 4402a993d1 | |||
| cb87456575 | |||
| 3d0c7766f0 | |||
| e76b514872 | |||
| 5bb9588f09 | |||
| d895854dcc | |||
| 55a298f05f | |||
| 0aea0f64ac | |||
| 6c80bf0512 | |||
| 9c57b493a7 | |||
| 609499543e | |||
| 768f7c6247 | |||
| 86b635104c | |||
| e158871a9a | |||
| 1ca50e2bc0 | |||
| 74d7056284 | |||
| 0ba91040f1 | |||
| dc83d7bef2 | |||
| 9d4798a124 | |||
| d552d3d3ec | |||
| 42a826cb73 | |||
| 05b43078b9 | |||
| 7c1be58860 | |||
| 9bca4b608a | |||
| 35298907bc |
+17
-4
@@ -1,7 +1,20 @@
|
|||||||
# Cyclone — environment configuration
|
# Cyclone — environment configuration
|
||||||
# Copy this file to `.env.local` and fill in values for your environment.
|
# Copy this file to `.env.local` and fill in values for your environment.
|
||||||
|
|
||||||
# Base URL for the Python (FastAPI) backend that powers the Upload page and
|
# Required on first boot. Cyclone refuses to start without these unless
|
||||||
# the real /api/parse-837 + /api/parse-835 endpoints. Leave empty to keep
|
# at least one user already exists (e.g. seeded via `python -m cyclone users create`).
|
||||||
# the in-memory sample data store and disable real EDI parsing.
|
# Min 12 chars for password.
|
||||||
VITE_API_BASE_URL=http://localhost:8000
|
CYCLONE_ADMIN_USERNAME=admin
|
||||||
|
CYCLONE_ADMIN_PASSWORD=change-me-to-a-strong-password-min-12-chars
|
||||||
|
|
||||||
|
# Base URL for the Python (FastAPI) backend. Leave empty for the
|
||||||
|
# Docker deployment (nginx proxies /api/* to backend on compose network).
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
|
# Optional. Set to 1 if you're behind an HTTPS reverse proxy and want
|
||||||
|
# the session cookie to include the Secure flag.
|
||||||
|
# CYCLONE_BEHIND_HTTPS=1
|
||||||
|
|
||||||
|
# Optional. Set to 1 to disable auth entirely (DEV ONLY). When set,
|
||||||
|
# the backend auto-grants admin access without checking credentials.
|
||||||
|
# CYCLONE_AUTH_DISABLED=0
|
||||||
@@ -111,6 +111,49 @@ npm run build
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Cyclone ships with username/password authentication and three predefined roles.
|
||||||
|
|
||||||
|
**Roles:**
|
||||||
|
|
||||||
|
| Role | Can read | Can write (upload, parse, reconcile) | Can manage users |
|
||||||
|
| -------- | -------- | ------------------------------------ | ---------------- |
|
||||||
|
| `viewer` | ✅ | ❌ | ❌ |
|
||||||
|
| `user` | ✅ | ✅ | ❌ |
|
||||||
|
| `admin` | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
**Bootstrap.** On first start, set `CYCLONE_ADMIN_USERNAME` and
|
||||||
|
`CYCLONE_ADMIN_PASSWORD` (min 12 chars) in your environment. Cyclone creates the
|
||||||
|
first admin automatically. On subsequent starts these env vars are ignored, so
|
||||||
|
rotating the bootstrap password doesn't affect an already-seeded admin — use the
|
||||||
|
CLI below to reset it. When running via `docker compose`, both vars are
|
||||||
|
required: compose refuses to start with a clear error if either is missing.
|
||||||
|
|
||||||
|
**CLI.** Manage users from the command line:
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m cyclone users create alice --role user --password 'hunter2hunter2'
|
||||||
|
python -m cyclone users list
|
||||||
|
python -m cyclone users disable alice
|
||||||
|
python -m cyclone users reset-password alice
|
||||||
|
python -m cyclone users set-role alice --role admin
|
||||||
|
```
|
||||||
|
|
||||||
|
**Login.** Browse to `http://localhost:5173` (dev) or `http://localhost:8081`
|
||||||
|
(Docker), sign in on the `/login` page, and you'll be redirected to the
|
||||||
|
dashboard. Sessions are stored server-side in SQLite with a 24-hour sliding
|
||||||
|
expiry — every authenticated request refreshes the TTL, so an active user
|
||||||
|
never gets logged out.
|
||||||
|
|
||||||
|
**Dev escape hatch.** Set `CYCLONE_AUTH_DISABLED=1` to bypass auth entirely
|
||||||
|
(the backend auto-grants admin on every request). **NEVER set this in
|
||||||
|
production** — it's a single env-var trip from wide-open to the public
|
||||||
|
internet. The Docker compose file does not honor this flag.
|
||||||
|
|
||||||
|
See `docs/superpowers/specs/2026-06-22-cyclone-auth-design.md` for the full
|
||||||
|
design.
|
||||||
|
|
||||||
## Live updates
|
## Live updates
|
||||||
|
|
||||||
The Claims, Remittances, and Activity pages stay current without
|
The Claims, Remittances, and Activity pages stay current without
|
||||||
|
|||||||
+233
@@ -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);
|
||||||
|
});
|
||||||
@@ -16,6 +16,15 @@ dependencies = [
|
|||||||
"sqlalchemy>=2.0,<3",
|
"sqlalchemy>=2.0,<3",
|
||||||
"pyyaml>=6.0,<7",
|
"pyyaml>=6.0,<7",
|
||||||
"keyring>=25.0,<26",
|
"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]
|
[project.optional-dependencies]
|
||||||
@@ -24,6 +33,7 @@ dev = [
|
|||||||
"pytest-cov>=4.1",
|
"pytest-cov>=4.1",
|
||||||
"pytest-asyncio>=0.23,<1",
|
"pytest-asyncio>=0.23,<1",
|
||||||
"httpx>=0.27,<1",
|
"httpx>=0.27,<1",
|
||||||
|
"pytest-randomly>=4.1",
|
||||||
]
|
]
|
||||||
sqlcipher = [
|
sqlcipher = [
|
||||||
# SP12: encryption at rest. Optional — without it the DB is plain SQLite.
|
# SP12: encryption at rest. Optional — without it the DB is plain SQLite.
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
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":
|
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
|
||||||
port = os.environ.get("CYCLONE_PORT", "8000")
|
port = os.environ.get("CYCLONE_PORT", "8000")
|
||||||
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
|
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
|
||||||
|
|||||||
+422
-74
@@ -29,18 +29,34 @@ from contextlib import asynccontextmanager
|
|||||||
from time import monotonic
|
from time import monotonic
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
|
from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from cyclone import __version__, db
|
from cyclone import __version__, db
|
||||||
from cyclone.db import Claim, ClaimState, Remittance
|
from cyclone.auth.deps import matrix_gate
|
||||||
|
from cyclone.db import Batch, Claim, ClaimState, Remittance
|
||||||
from sqlalchemy import desc, or_
|
from sqlalchemy import desc, or_
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from cyclone.inbox_state import apply_999_rejections
|
from cyclone.inbox_state import apply_999_rejections
|
||||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||||
from cyclone.audit_log import AuditEvent, append_event, verify_chain
|
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.exceptions import CycloneParseError
|
||||||
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
|
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
|
||||||
from cyclone.parsers.models_270 import (
|
from cyclone.parsers.models_270 import (
|
||||||
@@ -268,9 +284,19 @@ app.add_middleware(SecurityHeadersMiddleware)
|
|||||||
from cyclone.api_routers import acks, admin, health, ta1_acks # noqa: E402
|
from cyclone.api_routers import acks, admin, health, ta1_acks # noqa: E402
|
||||||
|
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(acks.router)
|
app.include_router(acks.router, dependencies=[Depends(matrix_gate)])
|
||||||
app.include_router(ta1_acks.router)
|
app.include_router(ta1_acks.router, dependencies=[Depends(matrix_gate)])
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router, dependencies=[Depends(matrix_gate)])
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
def _resolve_payer(name: str) -> PayerConfig:
|
||||||
@@ -326,7 +352,7 @@ async def _unhandled_exception_handler(request: Request, exc: Exception) -> JSON
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/parse-837")
|
@app.post("/api/parse-837", dependencies=[Depends(matrix_gate)])
|
||||||
async def parse_837(
|
async def parse_837(
|
||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -420,11 +446,18 @@ async def parse_837(
|
|||||||
ack_body = _build_and_persist_ack(rec.id)
|
ack_body = _build_and_persist_ack(rec.id)
|
||||||
if ack_body is not None:
|
if ack_body is not None:
|
||||||
body["ack"] = ack_body
|
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)
|
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(
|
return StreamingResponse(
|
||||||
_ndjson_stream(result),
|
_ndjson_stream(result, batch_id=rec.id),
|
||||||
media_type="application/x-ndjson",
|
media_type="application/x-ndjson",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -505,7 +538,7 @@ def _reconciliation_summary_for_batch(batch_id: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/parse-835")
|
@app.post("/api/parse-835", dependencies=[Depends(matrix_gate)])
|
||||||
async def parse_835_endpoint(
|
async def parse_835_endpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -606,9 +639,12 @@ async def parse_835_endpoint(
|
|||||||
body["reconciliation"] = _reconciliation_summary_for_batch(rec.id)
|
body["reconciliation"] = _reconciliation_summary_for_batch(rec.id)
|
||||||
return JSONResponse(content=body)
|
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(
|
return StreamingResponse(
|
||||||
_ndjson_stream_835(result),
|
_ndjson_stream_835(result, batch_id=rec.id),
|
||||||
media_type="application/x-ndjson",
|
media_type="application/x-ndjson",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -653,7 +689,7 @@ def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
|||||||
return f"999-{(interchange_control_number or '').strip() or '000000001'}"
|
return f"999-{(interchange_control_number or '').strip() or '000000001'}"
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/parse-999")
|
@app.post("/api/parse-999", dependencies=[Depends(matrix_gate)])
|
||||||
async def parse_999_endpoint(
|
async def parse_999_endpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -746,6 +782,7 @@ async def parse_999_endpoint(
|
|||||||
entity_id=cid,
|
entity_id=cid,
|
||||||
payload={"source_batch_id": synthetic_id, "ack_id": row.id},
|
payload={"source_batch_id": synthetic_id, "ack_id": row.id},
|
||||||
actor="999-parser",
|
actor="999-parser",
|
||||||
|
user_id=_actor_user_id(request),
|
||||||
))
|
))
|
||||||
audit_s.commit()
|
audit_s.commit()
|
||||||
|
|
||||||
@@ -779,7 +816,7 @@ def _ta1_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
|||||||
return f"TA1-{(interchange_control_number or '').strip() or '000000001'}"
|
return f"TA1-{(interchange_control_number or '').strip() or '000000001'}"
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/parse-ta1")
|
@app.post("/api/parse-ta1", dependencies=[Depends(matrix_gate)])
|
||||||
async def parse_ta1_endpoint(
|
async def parse_ta1_endpoint(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -875,7 +912,7 @@ def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
|||||||
return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"
|
return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/parse-277ca")
|
@app.post("/api/parse-277ca", dependencies=[Depends(matrix_gate)])
|
||||||
async def parse_277ca_endpoint(
|
async def parse_277ca_endpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -969,6 +1006,7 @@ async def parse_277ca_endpoint(
|
|||||||
"277ca_id": row.id,
|
"277ca_id": row.id,
|
||||||
},
|
},
|
||||||
actor="277ca-parser",
|
actor="277ca-parser",
|
||||||
|
user_id=_actor_user_id(request),
|
||||||
))
|
))
|
||||||
audit_s.commit()
|
audit_s.commit()
|
||||||
if apply_result.orphans:
|
if apply_result.orphans:
|
||||||
@@ -994,7 +1032,7 @@ async def parse_277ca_endpoint(
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/277ca-acks")
|
@app.get("/api/277ca-acks", dependencies=[Depends(matrix_gate)])
|
||||||
def list_277ca_acks_endpoint(
|
def list_277ca_acks_endpoint(
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -1004,7 +1042,7 @@ def list_277ca_acks_endpoint(
|
|||||||
return {"total": len(rows), "items": items}
|
return {"total": len(rows), "items": items}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/277ca-acks/{ack_id}")
|
@app.get("/api/277ca-acks/{ack_id}", dependencies=[Depends(matrix_gate)])
|
||||||
def get_277ca_ack_endpoint(ack_id: int) -> dict:
|
def get_277ca_ack_endpoint(ack_id: int) -> dict:
|
||||||
"""Return one persisted 277CA ACK row with its parsed detail."""
|
"""Return one persisted 277CA ACK row with its parsed detail."""
|
||||||
row = store.get_277ca_ack(ack_id)
|
row = store.get_277ca_ack(ack_id)
|
||||||
@@ -1068,7 +1106,7 @@ def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/inbox/lanes")
|
@app.get("/api/inbox/lanes", dependencies=[Depends(matrix_gate)])
|
||||||
def inbox_lanes():
|
def inbox_lanes():
|
||||||
"""Return all Inbox lanes in one call."""
|
"""Return all Inbox lanes in one call."""
|
||||||
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
|
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
|
||||||
@@ -1086,7 +1124,7 @@ def inbox_lanes():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/inbox/candidates/{remit_id}/match")
|
@app.post("/api/inbox/candidates/{remit_id}/match", dependencies=[Depends(matrix_gate)])
|
||||||
def inbox_match_candidate(remit_id: str, body: dict):
|
def inbox_match_candidate(remit_id: str, body: dict):
|
||||||
"""Manually link a remit to a claim."""
|
"""Manually link a remit to a claim."""
|
||||||
claim_id = body.get("claim_id")
|
claim_id = body.get("claim_id")
|
||||||
@@ -1115,7 +1153,7 @@ def inbox_match_candidate(remit_id: str, body: dict):
|
|||||||
return {"ok": True, "claim_id": claim_id, "remit_id": remit_id}
|
return {"ok": True, "claim_id": claim_id, "remit_id": remit_id}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/inbox/candidates/dismiss")
|
@app.post("/api/inbox/candidates/dismiss", dependencies=[Depends(matrix_gate)])
|
||||||
def inbox_dismiss_candidates(body: dict):
|
def inbox_dismiss_candidates(body: dict):
|
||||||
"""Add candidate pairs to the session-scoped dismissed set."""
|
"""Add candidate pairs to the session-scoped dismissed set."""
|
||||||
pairs = body.get("pairs") or []
|
pairs = body.get("pairs") or []
|
||||||
@@ -1141,7 +1179,7 @@ def inbox_dismiss_candidates(body: dict):
|
|||||||
# (the timestamp is not bumped). Returns the count actually transitioned
|
# (the timestamp is not bumped). Returns the count actually transitioned
|
||||||
# so the UI can show "3 of 5 were already acknowledged".
|
# so the UI can show "3 of 5 were already acknowledged".
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@app.post("/api/inbox/payer-rejected/acknowledge")
|
@app.post("/api/inbox/payer-rejected/acknowledge", dependencies=[Depends(matrix_gate)])
|
||||||
def inbox_acknowledge_payer_rejected(body: dict):
|
def inbox_acknowledge_payer_rejected(body: dict):
|
||||||
"""Mark Payer-Rejected claims as acknowledged by the operator."""
|
"""Mark Payer-Rejected claims as acknowledged by the operator."""
|
||||||
claim_ids = body.get("claim_ids") or []
|
claim_ids = body.get("claim_ids") or []
|
||||||
@@ -1201,7 +1239,7 @@ def inbox_acknowledge_payer_rejected(body: dict):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/inbox/rejected/resubmit")
|
@app.post("/api/inbox/rejected/resubmit", dependencies=[Depends(matrix_gate)])
|
||||||
def inbox_resubmit_rejected(
|
def inbox_resubmit_rejected(
|
||||||
request: Request,
|
request: Request,
|
||||||
body: dict,
|
body: dict,
|
||||||
@@ -1295,7 +1333,216 @@ def inbox_resubmit_rejected(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/inbox/export.csv")
|
@app.post("/api/batches/{batch_id}/export-837", dependencies=[Depends(matrix_gate)])
|
||||||
|
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", dependencies=[Depends(matrix_gate)])
|
||||||
def inbox_export_csv(lane: str):
|
def inbox_export_csv(lane: str):
|
||||||
"""Stream a CSV for a single lane."""
|
"""Stream a CSV for a single lane."""
|
||||||
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
|
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
|
||||||
@@ -1348,7 +1595,7 @@ def _batch_summary_claim_count(rec: BatchRecord) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/batches")
|
@app.get("/api/batches", dependencies=[Depends(matrix_gate)])
|
||||||
def list_batches(
|
def list_batches(
|
||||||
request: Request,
|
request: Request,
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
@@ -1382,7 +1629,7 @@ def list_batches(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/batches/{batch_id}")
|
@app.get("/api/batches/{batch_id}", dependencies=[Depends(matrix_gate)])
|
||||||
def get_batch(batch_id: str) -> Any:
|
def get_batch(batch_id: str) -> Any:
|
||||||
rec = store.get(batch_id)
|
rec = store.get(batch_id)
|
||||||
if rec is None:
|
if rec is None:
|
||||||
@@ -1393,7 +1640,7 @@ def get_batch(batch_id: str) -> Any:
|
|||||||
return json.loads(rec.result.model_dump_json())
|
return json.loads(rec.result.model_dump_json())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/claims")
|
@app.get("/api/claims", dependencies=[Depends(matrix_gate)])
|
||||||
def list_claims(
|
def list_claims(
|
||||||
request: Request,
|
request: Request,
|
||||||
batch_id: str | None = Query(None),
|
batch_id: str | None = Query(None),
|
||||||
@@ -1439,7 +1686,7 @@ def list_claims(
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/claims/stream")
|
@app.get("/api/claims/stream", dependencies=[Depends(matrix_gate)])
|
||||||
async def claims_stream(
|
async def claims_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
status: str | None = Query(None),
|
status: str | None = Query(None),
|
||||||
@@ -1486,7 +1733,7 @@ async def claims_stream(
|
|||||||
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/claims/{claim_id}")
|
@app.get("/api/claims/{claim_id}", dependencies=[Depends(matrix_gate)])
|
||||||
def get_claim_detail_endpoint(claim_id: str) -> dict:
|
def get_claim_detail_endpoint(claim_id: str) -> dict:
|
||||||
"""Return one claim with full drawer context (SP4).
|
"""Return one claim with full drawer context (SP4).
|
||||||
|
|
||||||
@@ -1511,7 +1758,7 @@ def get_claim_detail_endpoint(claim_id: str) -> dict:
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/claims/{claim_id}/serialize-837")
|
@app.get("/api/claims/{claim_id}/serialize-837", dependencies=[Depends(matrix_gate)])
|
||||||
def serialize_claim_as_837(claim_id: str):
|
def serialize_claim_as_837(claim_id: str):
|
||||||
"""Return the claim as a regenerated X12 837P file (SP8).
|
"""Return the claim as a regenerated X12 837P file (SP8).
|
||||||
|
|
||||||
@@ -1563,7 +1810,7 @@ def serialize_claim_as_837(claim_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/claims/{claim_id}/line-reconciliation")
|
@app.get("/api/claims/{claim_id}/line-reconciliation", dependencies=[Depends(matrix_gate)])
|
||||||
def get_claim_line_reconciliation(claim_id: str) -> dict:
|
def get_claim_line_reconciliation(claim_id: str) -> dict:
|
||||||
"""Per-line reconciliation view for the ClaimDrawer tab.
|
"""Per-line reconciliation view for the ClaimDrawer tab.
|
||||||
|
|
||||||
@@ -1760,7 +2007,7 @@ def _svc_to_dict(svc) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/reconciliation/unmatched")
|
@app.get("/api/reconciliation/unmatched", dependencies=[Depends(matrix_gate)])
|
||||||
def get_reconciliation_unmatched() -> dict:
|
def get_reconciliation_unmatched() -> dict:
|
||||||
"""Return unmatched Claims (left) and unmatched Remittances (right).
|
"""Return unmatched Claims (left) and unmatched Remittances (right).
|
||||||
|
|
||||||
@@ -1777,7 +2024,7 @@ def get_reconciliation_unmatched() -> dict:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/batch-diff")
|
@app.get("/api/batch-diff", dependencies=[Depends(matrix_gate)])
|
||||||
def get_batch_diff(
|
def get_batch_diff(
|
||||||
a: str | None = Query(None),
|
a: str | None = Query(None),
|
||||||
b: str | None = Query(None),
|
b: str | None = Query(None),
|
||||||
@@ -1824,7 +2071,7 @@ def get_batch_diff(
|
|||||||
return diff_batches_to_wire(a_rec, b_rec)
|
return diff_batches_to_wire(a_rec, b_rec)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/reconciliation/match")
|
@app.post("/api/reconciliation/match", dependencies=[Depends(matrix_gate)])
|
||||||
def post_reconciliation_match(body: dict) -> dict:
|
def post_reconciliation_match(body: dict) -> dict:
|
||||||
"""Manually pair a Claim with a Remittance (operator override).
|
"""Manually pair a Claim with a Remittance (operator override).
|
||||||
|
|
||||||
@@ -1869,7 +2116,7 @@ def post_reconciliation_match(body: dict) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/reconciliation/unmatch")
|
@app.post("/api/reconciliation/unmatch", dependencies=[Depends(matrix_gate)])
|
||||||
def post_reconciliation_unmatch(body: dict) -> dict:
|
def post_reconciliation_unmatch(body: dict) -> dict:
|
||||||
"""Remove the current match for a Claim; reset Claim to submitted.
|
"""Remove the current match for a Claim; reset Claim to submitted.
|
||||||
|
|
||||||
@@ -1901,7 +2148,7 @@ def post_reconciliation_unmatch(body: dict) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/remittances")
|
@app.get("/api/remittances", dependencies=[Depends(matrix_gate)])
|
||||||
def list_remittances(
|
def list_remittances(
|
||||||
request: Request,
|
request: Request,
|
||||||
batch_id: str | None = Query(None),
|
batch_id: str | None = Query(None),
|
||||||
@@ -1940,7 +2187,7 @@ def list_remittances(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/remittances/stream")
|
@app.get("/api/remittances/stream", dependencies=[Depends(matrix_gate)])
|
||||||
async def remittances_stream(
|
async def remittances_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
payer: str | None = Query(None),
|
payer: str | None = Query(None),
|
||||||
@@ -1979,7 +2226,7 @@ async def remittances_stream(
|
|||||||
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/remittances/{remittance_id}")
|
@app.get("/api/remittances/{remittance_id}", dependencies=[Depends(matrix_gate)])
|
||||||
def get_remittance(remittance_id: str) -> dict:
|
def get_remittance(remittance_id: str) -> dict:
|
||||||
"""Return one remittance with its labeled CAS ``adjustments`` array.
|
"""Return one remittance with its labeled CAS ``adjustments`` array.
|
||||||
|
|
||||||
@@ -1996,7 +2243,7 @@ def get_remittance(remittance_id: str) -> dict:
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/providers")
|
@app.get("/api/providers", dependencies=[Depends(matrix_gate)])
|
||||||
def list_providers(
|
def list_providers(
|
||||||
request: Request,
|
request: Request,
|
||||||
npi: str | None = Query(None),
|
npi: str | None = Query(None),
|
||||||
@@ -2026,7 +2273,7 @@ def list_providers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/activity")
|
@app.get("/api/activity", dependencies=[Depends(matrix_gate)])
|
||||||
def list_activity(
|
def list_activity(
|
||||||
request: Request,
|
request: Request,
|
||||||
kind: str | None = Query(None),
|
kind: str | None = Query(None),
|
||||||
@@ -2053,7 +2300,7 @@ def list_activity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/activity/stream")
|
@app.get("/api/activity/stream", dependencies=[Depends(matrix_gate)])
|
||||||
async def activity_stream(
|
async def activity_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
kind: str | None = Query(None),
|
kind: str | None = Query(None),
|
||||||
@@ -2201,7 +2448,7 @@ def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]:
|
|||||||
return result, service_type_code
|
return result, service_type_code
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/eligibility/request")
|
@app.post("/api/eligibility/request", dependencies=[Depends(matrix_gate)])
|
||||||
def post_eligibility_request(body: dict) -> Any:
|
def post_eligibility_request(body: dict) -> Any:
|
||||||
"""Build a 270 eligibility inquiry from a small JSON body.
|
"""Build a 270 eligibility inquiry from a small JSON body.
|
||||||
|
|
||||||
@@ -2227,7 +2474,7 @@ def post_eligibility_request(body: dict) -> Any:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/eligibility/parse-271")
|
@app.post("/api/eligibility/parse-271", dependencies=[Depends(matrix_gate)])
|
||||||
async def post_eligibility_parse_271(
|
async def post_eligibility_parse_271(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -2287,7 +2534,7 @@ async def post_eligibility_parse_271(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/clearhouse")
|
@app.get("/api/clearhouse", dependencies=[Depends(matrix_gate)])
|
||||||
def get_clearhouse():
|
def get_clearhouse():
|
||||||
"""Return the singleton clearhouse config (dzinesco's identity, SFTP block, filename block)."""
|
"""Return the singleton clearhouse config (dzinesco's identity, SFTP block, filename block)."""
|
||||||
ch = store.get_clearhouse()
|
ch = store.get_clearhouse()
|
||||||
@@ -2296,8 +2543,8 @@ def get_clearhouse():
|
|||||||
return json.loads(ch.model_dump_json())
|
return json.loads(ch.model_dump_json())
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/clearhouse/submit")
|
@app.post("/api/clearhouse/submit", dependencies=[Depends(matrix_gate)])
|
||||||
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.
|
"""Submit a batch of claims to the clearhouse (SFTP). SP9: stub.
|
||||||
|
|
||||||
Body: ``{"claim_ids": [...], "payer_id": "CO_TXIX"}``
|
Body: ``{"claim_ids": [...], "payer_id": "CO_TXIX"}``
|
||||||
@@ -2321,6 +2568,51 @@ def submit_to_clearhouse(body: dict):
|
|||||||
if ch is None:
|
if ch is None:
|
||||||
raise HTTPException(status_code=500, detail="clearhouse not seeded")
|
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)
|
client = make_client(ch.sftp_block)
|
||||||
results = []
|
results = []
|
||||||
for cid in claim_ids:
|
for cid in claim_ids:
|
||||||
@@ -2329,6 +2621,34 @@ def submit_to_clearhouse(body: dict):
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
results.append({"claim_id": cid, "ok": False, "error": str(exc)})
|
results.append({"claim_id": cid, "ok": False, "error": str(exc)})
|
||||||
continue
|
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")
|
filename = build_outbound_filename(ch.tpid, "837P")
|
||||||
remote = f"{ch.sftp_block.paths['outbound']}/{filename}"
|
remote = f"{ch.sftp_block.paths['outbound']}/{filename}"
|
||||||
staging_path = client.write_file(remote, x12_text.encode("utf-8"))
|
staging_path = client.write_file(remote, x12_text.encode("utf-8"))
|
||||||
@@ -2352,14 +2672,26 @@ def submit_to_clearhouse(body: dict):
|
|||||||
"stub": ch.sftp_block.stub,
|
"stub": ch.sftp_block.stub,
|
||||||
},
|
},
|
||||||
actor="clearhouse-submit",
|
actor="clearhouse-submit",
|
||||||
|
user_id=_actor_user_id(request),
|
||||||
))
|
))
|
||||||
audit_s.commit()
|
audit_s.commit()
|
||||||
return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub}
|
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
|
"""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.parsers.serialize_837 import serialize_837
|
||||||
from cyclone import db
|
from cyclone import db
|
||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
@@ -2371,15 +2703,17 @@ def _serialize_claim_for_submit(claim_id: str) -> str:
|
|||||||
from cyclone.parsers.parse_837 import parse
|
from cyclone.parsers.parse_837 import parse
|
||||||
raw = row.raw_json or {}
|
raw = row.raw_json or {}
|
||||||
# Reconstruct minimal ClaimOutput from raw_json; this is best-effort.
|
# 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.
|
"""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
|
For SP9 this delegates to the existing serialize_837 helper if the
|
||||||
claim has a complete raw_segments array. Otherwise it returns a
|
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.serialize_837 import serialize_837
|
||||||
from cyclone.parsers.parse_837 import parse
|
from cyclone.parsers.parse_837 import parse
|
||||||
@@ -2389,14 +2723,14 @@ def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
|
|||||||
if isinstance(raw, dict) and raw.get("x12_text"):
|
if isinstance(raw, dict) and raw.get("x12_text"):
|
||||||
result = parse(raw["x12_text"])
|
result = parse(raw["x12_text"])
|
||||||
if result.claims:
|
if result.claims:
|
||||||
return serialize_837(result.claims[0])
|
return serialize_837(result.claims[0], **kwargs)
|
||||||
# Fallback: raise so the caller sees an error.
|
# Fallback: raise so the caller sees an error.
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"claim {claim_row.id!r} cannot be re-serialized: no stored x12_text"
|
f"claim {claim_row.id!r} cannot be re-serialized: no stored x12_text"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config/providers")
|
@app.get("/api/config/providers", dependencies=[Depends(matrix_gate)])
|
||||||
def list_configured_providers(is_active: bool | None = Query(default=True)):
|
def list_configured_providers(is_active: bool | None = Query(default=True)):
|
||||||
"""List the configured provider rows (3 NPIs for SP9)."""
|
"""List the configured provider rows (3 NPIs for SP9)."""
|
||||||
return [json.loads(p.model_dump_json()) for p in store.list_providers(is_active=is_active)]
|
return [json.loads(p.model_dump_json()) for p in store.list_providers(is_active=is_active)]
|
||||||
@@ -2407,7 +2741,7 @@ def list_configured_providers(is_active: bool | None = Query(default=True)):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/audit-log")
|
@app.get("/api/admin/audit-log", dependencies=[Depends(matrix_gate)])
|
||||||
def list_audit_log_endpoint(
|
def list_audit_log_endpoint(
|
||||||
entity_type: str | None = Query(default=None),
|
entity_type: str | None = Query(default=None),
|
||||||
entity_id: str | None = Query(default=None),
|
entity_id: str | None = Query(default=None),
|
||||||
@@ -2449,7 +2783,7 @@ def list_audit_log_endpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/audit-log/verify")
|
@app.get("/api/admin/audit-log/verify", dependencies=[Depends(matrix_gate)])
|
||||||
def verify_audit_log_endpoint() -> Any:
|
def verify_audit_log_endpoint() -> Any:
|
||||||
"""Walk the audit-log chain and verify every row's hash.
|
"""Walk the audit-log chain and verify every row's hash.
|
||||||
|
|
||||||
@@ -2489,7 +2823,7 @@ from cyclone import secrets as _secrets
|
|||||||
_db_rotate_lock = _threading.Lock()
|
_db_rotate_lock = _threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/db/rotate-key")
|
@app.post("/api/admin/db/rotate-key", dependencies=[Depends(matrix_gate)])
|
||||||
def rotate_db_key_endpoint(body: dict | None = None) -> Any:
|
def rotate_db_key_endpoint(body: dict | None = None) -> Any:
|
||||||
"""Generate a fresh DB key, re-encrypt the DB, update the Keychain.
|
"""Generate a fresh DB key, re-encrypt the DB, update the Keychain.
|
||||||
|
|
||||||
@@ -2649,7 +2983,7 @@ def _backup_or_503():
|
|||||||
raise HTTPException(status_code=503, detail=str(exc))
|
raise HTTPException(status_code=503, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/create")
|
@app.post("/api/admin/backup/create", dependencies=[Depends(matrix_gate)])
|
||||||
def backup_create() -> Any:
|
def backup_create() -> Any:
|
||||||
"""Take an encrypted backup right now. Returns the new backup metadata."""
|
"""Take an encrypted backup right now. Returns the new backup metadata."""
|
||||||
from cyclone import audit_log as _audit
|
from cyclone import audit_log as _audit
|
||||||
@@ -2703,7 +3037,7 @@ def backup_create() -> Any:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/backup/list")
|
@app.get("/api/admin/backup/list", dependencies=[Depends(matrix_gate)])
|
||||||
def backup_list(
|
def backup_list(
|
||||||
limit: int = Query(default=100, ge=1, le=1000),
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
status: str | None = Query(default=None),
|
status: str | None = Query(default=None),
|
||||||
@@ -2732,7 +3066,7 @@ def backup_list(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/backup/status")
|
@app.get("/api/admin/backup/status", dependencies=[Depends(matrix_gate)])
|
||||||
def backup_status() -> Any:
|
def backup_status() -> Any:
|
||||||
"""Snapshot of the backup subsystem (counts, disk usage, last run)."""
|
"""Snapshot of the backup subsystem (counts, disk usage, last run)."""
|
||||||
svc = _backup_or_503()
|
svc = _backup_or_503()
|
||||||
@@ -2746,7 +3080,7 @@ def backup_status() -> Any:
|
|||||||
return snap
|
return snap
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/{backup_id}/verify")
|
@app.post("/api/admin/backup/{backup_id}/verify", dependencies=[Depends(matrix_gate)])
|
||||||
def backup_verify(backup_id: int) -> Any:
|
def backup_verify(backup_id: int) -> Any:
|
||||||
"""Decrypt + checksum-verify a backup against its sidecar."""
|
"""Decrypt + checksum-verify a backup against its sidecar."""
|
||||||
svc = _backup_or_503()
|
svc = _backup_or_503()
|
||||||
@@ -2765,7 +3099,7 @@ def backup_verify(backup_id: int) -> Any:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/{backup_id}/restore/initiate")
|
@app.post("/api/admin/backup/{backup_id}/restore/initiate", dependencies=[Depends(matrix_gate)])
|
||||||
def backup_restore_initiate(backup_id: int) -> Any:
|
def backup_restore_initiate(backup_id: int) -> Any:
|
||||||
"""First step of the two-step restore. Returns a ``restore_token``."""
|
"""First step of the two-step restore. Returns a ``restore_token``."""
|
||||||
svc = _backup_or_503()
|
svc = _backup_or_503()
|
||||||
@@ -2793,7 +3127,7 @@ def backup_restore_initiate(backup_id: int) -> Any:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/{backup_id}/restore/confirm")
|
@app.post("/api/admin/backup/{backup_id}/restore/confirm", dependencies=[Depends(matrix_gate)])
|
||||||
def backup_restore_confirm(
|
def backup_restore_confirm(
|
||||||
backup_id: int,
|
backup_id: int,
|
||||||
body: dict | None = None,
|
body: dict | None = None,
|
||||||
@@ -2845,7 +3179,7 @@ def backup_restore_confirm(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/prune")
|
@app.post("/api/admin/backup/prune", dependencies=[Depends(matrix_gate)])
|
||||||
def backup_prune() -> Any:
|
def backup_prune() -> Any:
|
||||||
"""Apply the retention policy now. Returns the deleted paths."""
|
"""Apply the retention policy now. Returns the deleted paths."""
|
||||||
from cyclone import audit_log as _audit
|
from cyclone import audit_log as _audit
|
||||||
@@ -2873,7 +3207,7 @@ def backup_prune() -> Any:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/scheduler/start")
|
@app.post("/api/admin/backup/scheduler/start", dependencies=[Depends(matrix_gate)])
|
||||||
async def backup_scheduler_start() -> Any:
|
async def backup_scheduler_start() -> Any:
|
||||||
"""Begin the backup scheduler loop."""
|
"""Begin the backup scheduler loop."""
|
||||||
try:
|
try:
|
||||||
@@ -2884,7 +3218,7 @@ async def backup_scheduler_start() -> Any:
|
|||||||
return {"status": sched.status().as_dict()}
|
return {"status": sched.status().as_dict()}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/scheduler/stop")
|
@app.post("/api/admin/backup/scheduler/stop", dependencies=[Depends(matrix_gate)])
|
||||||
async def backup_scheduler_stop() -> Any:
|
async def backup_scheduler_stop() -> Any:
|
||||||
"""Stop the backup scheduler loop."""
|
"""Stop the backup scheduler loop."""
|
||||||
try:
|
try:
|
||||||
@@ -2895,7 +3229,7 @@ async def backup_scheduler_stop() -> Any:
|
|||||||
return {"status": sched.status().as_dict()}
|
return {"status": sched.status().as_dict()}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/backup/scheduler/tick")
|
@app.post("/api/admin/backup/scheduler/tick", dependencies=[Depends(matrix_gate)])
|
||||||
async def backup_scheduler_tick() -> Any:
|
async def backup_scheduler_tick() -> Any:
|
||||||
"""Run one backup tick now (create + prune + audit)."""
|
"""Run one backup tick now (create + prune + audit)."""
|
||||||
try:
|
try:
|
||||||
@@ -2929,7 +3263,7 @@ def _scheduler_or_503():
|
|||||||
raise HTTPException(status_code=503, detail=str(exc))
|
raise HTTPException(status_code=503, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/scheduler/start")
|
@app.post("/api/admin/scheduler/start", dependencies=[Depends(matrix_gate)])
|
||||||
async def scheduler_start() -> Any:
|
async def scheduler_start() -> Any:
|
||||||
"""Begin polling the MFT inbound path every poll_interval_seconds."""
|
"""Begin polling the MFT inbound path every poll_interval_seconds."""
|
||||||
sched = _scheduler_or_503()
|
sched = _scheduler_or_503()
|
||||||
@@ -2937,7 +3271,7 @@ async def scheduler_start() -> Any:
|
|||||||
return {"status": sched.status().as_dict()}
|
return {"status": sched.status().as_dict()}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/scheduler/stop")
|
@app.post("/api/admin/scheduler/stop", dependencies=[Depends(matrix_gate)])
|
||||||
async def scheduler_stop() -> Any:
|
async def scheduler_stop() -> Any:
|
||||||
"""Stop polling. Waits up to 30s for the current tick to finish."""
|
"""Stop polling. Waits up to 30s for the current tick to finish."""
|
||||||
sched = _scheduler_or_503()
|
sched = _scheduler_or_503()
|
||||||
@@ -2945,7 +3279,7 @@ async def scheduler_stop() -> Any:
|
|||||||
return {"status": sched.status().as_dict()}
|
return {"status": sched.status().as_dict()}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/scheduler/tick")
|
@app.post("/api/admin/scheduler/tick", dependencies=[Depends(matrix_gate)])
|
||||||
async def scheduler_tick() -> Any:
|
async def scheduler_tick() -> Any:
|
||||||
"""Run a single poll cycle synchronously and return the result.
|
"""Run a single poll cycle synchronously and return the result.
|
||||||
|
|
||||||
@@ -2958,14 +3292,14 @@ async def scheduler_tick() -> Any:
|
|||||||
return {"ok": True, "tick": result.as_dict()}
|
return {"ok": True, "tick": result.as_dict()}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/scheduler/status")
|
@app.get("/api/admin/scheduler/status", dependencies=[Depends(matrix_gate)])
|
||||||
def scheduler_status() -> Any:
|
def scheduler_status() -> Any:
|
||||||
"""Return the scheduler's runtime snapshot (running, counters, last tick)."""
|
"""Return the scheduler's runtime snapshot (running, counters, last tick)."""
|
||||||
sched = _scheduler_or_503()
|
sched = _scheduler_or_503()
|
||||||
return sched.status().as_dict()
|
return sched.status().as_dict()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/scheduler/processed-files")
|
@app.get("/api/admin/scheduler/processed-files", dependencies=[Depends(matrix_gate)])
|
||||||
def scheduler_processed_files(
|
def scheduler_processed_files(
|
||||||
limit: int = Query(default=100, ge=1, le=1000),
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
status: str | None = Query(default=None),
|
status: str | None = Query(default=None),
|
||||||
@@ -3013,7 +3347,7 @@ def scheduler_processed_files(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config/providers/{npi}")
|
@app.get("/api/config/providers/{npi}", dependencies=[Depends(matrix_gate)])
|
||||||
def get_configured_provider(npi: str):
|
def get_configured_provider(npi: str):
|
||||||
p = store.get_provider(npi)
|
p = store.get_provider(npi)
|
||||||
if p is None:
|
if p is None:
|
||||||
@@ -3087,12 +3421,12 @@ def get_configured_provider(npi: str):
|
|||||||
return provider_dict
|
return provider_dict
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config/payers")
|
@app.get("/api/config/payers", dependencies=[Depends(matrix_gate)])
|
||||||
def list_configured_payers(is_active: bool | None = Query(default=True)):
|
def list_configured_payers(is_active: bool | None = Query(default=True)):
|
||||||
return [json.loads(p.model_dump_json()) for p in store.list_payers(is_active=is_active)]
|
return [json.loads(p.model_dump_json()) for p in store.list_payers(is_active=is_active)]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config/payers/{payer_id}/configs")
|
@app.get("/api/config/payers/{payer_id}/configs", dependencies=[Depends(matrix_gate)])
|
||||||
def list_payer_configs(payer_id: str):
|
def list_payer_configs(payer_id: str):
|
||||||
"""List all (transaction_type, config_json) blocks for a payer."""
|
"""List all (transaction_type, config_json) blocks for a payer."""
|
||||||
from cyclone import payers as payer_loader
|
from cyclone import payers as payer_loader
|
||||||
@@ -3143,7 +3477,7 @@ def _clear_summary_cache() -> None:
|
|||||||
# UI proves TTL-bounded staleness is unacceptable.
|
# UI proves TTL-bounded staleness is unacceptable.
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/payers/{payer_id}/summary")
|
@app.get("/api/payers/{payer_id}/summary", dependencies=[Depends(matrix_gate)])
|
||||||
def get_payer_summary(payer_id: str):
|
def get_payer_summary(payer_id: str):
|
||||||
"""Payer-level rollup for the drill-down panel.
|
"""Payer-level rollup for the drill-down panel.
|
||||||
|
|
||||||
@@ -3229,7 +3563,7 @@ def get_payer_summary(payer_id: str):
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/reload-config")
|
@app.post("/api/admin/reload-config", dependencies=[Depends(matrix_gate)])
|
||||||
def reload_config():
|
def reload_config():
|
||||||
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
|
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
|
||||||
from cyclone import payers as payer_loader
|
from cyclone import payers as payer_loader
|
||||||
@@ -3240,4 +3574,18 @@ def reload_config():
|
|||||||
return {"ok": True, "loaded": len(configs), "errors": []}
|
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, dependencies=[Depends(matrix_gate)])
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["app"]
|
__all__ = ["app"]
|
||||||
|
|||||||
@@ -93,19 +93,40 @@ def ndjson_stream_list(
|
|||||||
}) + "\n"
|
}) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def ndjson_stream_837(result: ParseResult) -> Iterator[bytes]:
|
def ndjson_stream_837(
|
||||||
"""Yield one JSON object per line: envelope → claims → summary."""
|
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 = (
|
envelope_obj = (
|
||||||
result.envelope.model_dump() if result.envelope is not None else None
|
result.envelope.model_dump() if result.envelope is not None else None
|
||||||
)
|
)
|
||||||
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
|
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
|
||||||
for claim in result.claims:
|
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": "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]:
|
def ndjson_stream_835(
|
||||||
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary."""
|
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": "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": "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")
|
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")
|
yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8")
|
||||||
for claim in result.claims:
|
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": "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:
|
def strict_rewrite_837(result: ParseResult) -> ParseResult:
|
||||||
|
|||||||
@@ -103,6 +103,12 @@ class AuditEvent:
|
|||||||
computed hash. Payload must be JSON-serializable; the audit_log
|
computed hash. Payload must be JSON-serializable; the audit_log
|
||||||
module handles the encoding so callers don't need to think about
|
module handles the encoding so callers don't need to think about
|
||||||
canonical form.
|
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
|
event_type: str
|
||||||
@@ -111,6 +117,7 @@ class AuditEvent:
|
|||||||
payload: dict[str, Any] = field(default_factory=dict)
|
payload: dict[str, Any] = field(default_factory=dict)
|
||||||
actor: str = "system"
|
actor: str = "system"
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
|
user_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def append_event(
|
def append_event(
|
||||||
@@ -155,6 +162,7 @@ def append_event(
|
|||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
prev_hash=prev_hash,
|
prev_hash=prev_hash,
|
||||||
hash=GENESIS_PREV_HASH, # placeholder; updated below
|
hash=GENESIS_PREV_HASH, # placeholder; updated below
|
||||||
|
user_id=event.user_id,
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
session.flush() # populate row.id
|
session.flush() # populate row.id
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Auth module — users, sessions, permissions, routes, admin, rate_limit."""
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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}'.")
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
async def matrix_gate(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""App-wide gate: requires auth, then enforces the PERMISSIONS matrix.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
* AUTH_DISABLED short-circuits (synthetic admin, no role check).
|
||||||
|
* No session cookie → 401 from get_current_user.
|
||||||
|
* Endpoint not in the matrix → 403 (fail-closed).
|
||||||
|
* User role not allowed for (method, path) → 403.
|
||||||
|
* Empty allowed-roles set (e.g. /api/healthz) → public, no role check.
|
||||||
|
|
||||||
|
Used as ``dependencies=[Depends(matrix_gate)]`` on every authenticated
|
||||||
|
route. Centralizing the gate here means the matrix is the source of
|
||||||
|
truth — no need to wire per-route ``require_role(...)`` calls.
|
||||||
|
"""
|
||||||
|
if AUTH_DISABLED:
|
||||||
|
return user
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
user_role = user.get("role")
|
||||||
|
if user_role not in {r.value for r in roles}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
return user
|
||||||
@@ -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]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -74,6 +74,12 @@ def main(ctx: click.Context, log_format: str | None, log_file: Path | None) -> N
|
|||||||
ctx.ensure_object(dict)
|
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")
|
@main.command("parse-837")
|
||||||
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
@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))
|
@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path))
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from sqlalchemy import (
|
|||||||
Numeric,
|
Numeric,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
|
func,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
prev_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
prev_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
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__ = (
|
__table_args__ = (
|
||||||
Index("idx_audit_log_entity", "entity_type", "entity_id"),
|
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)
|
filename_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
||||||
sftp_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)
|
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())
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ SP9. Source-of-truth spec:
|
|||||||
https://hcpf.colorado.gov/tp-x12-filenaming (HCPF X12 File Naming Standards Quick Guide)
|
https://hcpf.colorado.gov/tp-x12-filenaming (HCPF X12 File Naming Standards Quick Guide)
|
||||||
|
|
||||||
Outbound (we send):
|
Outbound (we send):
|
||||||
{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
|
tp{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
|
||||||
Example: 11525703-837P-20260620132243505-1of1.x12
|
Example: tp11525703-837P-20260620132243505-1of1.x12
|
||||||
|
|
||||||
Inbound (HPE sends to our ToHPE):
|
Inbound (HPE sends to our ToHPE):
|
||||||
TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
|
TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
|
||||||
@@ -28,14 +28,15 @@ from cyclone.providers import InboundFilename
|
|||||||
# Regexes
|
# Regexes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Outbound: 11525703-837P-20260620132243505-1of1.x12
|
# Outbound: tp11525703-837P-20260620132243505-1of1.x12
|
||||||
|
# - tp: literal "tp" prefix
|
||||||
# - tpid: 1+ digits
|
# - tpid: 1+ digits
|
||||||
# - tx: 1+ alnum
|
# - tx: 1+ alnum
|
||||||
# - ts: 17 digits (yyyymmddhhmmssSSS)
|
# - ts: 17 digits (yyyymmddhhmmssSSS)
|
||||||
# - seq: literal "1of1"
|
# - seq: literal "1of1"
|
||||||
# - ext: 1+ alnum
|
# - ext: 1+ alnum
|
||||||
OUTBOUND_RE = re.compile(
|
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
|
# Inbound: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
|
||||||
@@ -79,7 +80,8 @@ def build_outbound_filename(
|
|||||||
time in ``America/Denver`` is used.
|
time in ``America/Denver`` is used.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Filename like "11525703-837P-20260620132243505-1of1.x12"
|
Filename like "tp11525703-837P-20260620132243505-1of1.x12"
|
||||||
|
(note the ``tp`` prefix per HCPF outbound spec).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If tpid is non-numeric, tx contains invalid chars, or
|
ValueError: If tpid is non-numeric, tx contains invalid chars, or
|
||||||
@@ -99,7 +101,10 @@ def build_outbound_filename(
|
|||||||
# Format: yyyymmddhhmmssSSS — 17 digits total
|
# Format: yyyymmddhhmmssSSS — 17 digits total
|
||||||
ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}"
|
ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}"
|
||||||
assert len(ts) == 17
|
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);
|
||||||
@@ -63,6 +63,7 @@ class ClaimHeader(_Base):
|
|||||||
frequency_code: str | None = None
|
frequency_code: str | None = None
|
||||||
provider_signature: str | None = None
|
provider_signature: str | None = None
|
||||||
assignment: str | None = None
|
assignment: str | None = None
|
||||||
|
benefits_assignment_certification: str | None = None # CLM08 (Y/N)
|
||||||
release_of_info: str | None = None
|
release_of_info: str | None = None
|
||||||
prior_auth: str | None = None
|
prior_auth: str | None = None
|
||||||
|
|
||||||
@@ -87,6 +88,14 @@ class ServiceLine(_Base):
|
|||||||
place_of_service: str | None = None
|
place_of_service: str | None = None
|
||||||
service_date: date | None = None
|
service_date: date | None = None
|
||||||
provider_reference: str | 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):
|
class ValidationIssue(_Base):
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
|
|||||||
frequency_code=freq or None,
|
frequency_code=freq or None,
|
||||||
provider_signature=clm[6] if len(clm) > 6 else None,
|
provider_signature=clm[6] if len(clm) > 6 else None,
|
||||||
assignment=clm[7] if len(clm) > 7 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,
|
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:
|
except Exception:
|
||||||
units = None
|
units = None
|
||||||
place_of_service = seg[5] if len(seg) > 5 else 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
|
service_date: date | None = None
|
||||||
provider_ref: str | 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,
|
place_of_service=place_of_service,
|
||||||
service_date=service_date,
|
service_date=service_date,
|
||||||
provider_reference=provider_ref,
|
provider_reference=provider_ref,
|
||||||
|
dx_pointer=dx_pointer,
|
||||||
),
|
),
|
||||||
idx,
|
idx,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -112,7 +112,10 @@ def _build_gs(sender_id: str, receiver_id: str, group_control_number: str) -> st
|
|||||||
_FUNCTIONAL_ID_HEALTH_CARE,
|
_FUNCTIONAL_ID_HEALTH_CARE,
|
||||||
sender_id,
|
sender_id,
|
||||||
receiver_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(),
|
_today_hhmm(),
|
||||||
group_control_number,
|
group_control_number,
|
||||||
"X",
|
"X",
|
||||||
@@ -186,17 +189,30 @@ def _build_nm1(entity_id_qualifier: str, entity_type: str, name: str,
|
|||||||
return _ELEM.join(parts) + _SEG
|
return _ELEM.join(parts) + _SEG
|
||||||
|
|
||||||
|
|
||||||
def _build_per(contact_name: str | None, contact_phone: str | None) -> str:
|
def _build_per(
|
||||||
"""PER segment — submitter contact. Returns empty when no contact info."""
|
contact_name: str | None,
|
||||||
if not contact_name and not contact_phone:
|
contact_phone: str | None,
|
||||||
return ""
|
contact_email: str | None = None,
|
||||||
parts = [
|
email_qual: str = "EM",
|
||||||
"PER",
|
) -> str:
|
||||||
"IC", # PER01 — contact function code (Information Contact)
|
"""PER segment — submitter contact (Loop 1000A).
|
||||||
contact_name or "",
|
|
||||||
"TE", # PER03 — phone qualifier
|
X12 005010X222A1 *requires* at least one PER segment in Loop 1000A
|
||||||
contact_phone or "",
|
(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
|
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
|
return _ELEM.join(parts) + _SEG
|
||||||
|
|
||||||
|
|
||||||
def _build_sbr(relationship_code: str | None, member_id: str | None,
|
def _build_sbr(
|
||||||
payer_name: str | None) -> str:
|
individual_relationship_code: str | None,
|
||||||
|
claim_filing_indicator_code: str | None,
|
||||||
|
) -> str:
|
||||||
"""SBR segment — subscriber information.
|
"""SBR segment — subscriber information.
|
||||||
|
|
||||||
SBR01 (relationship code) defaults to ``"P"`` (Patient = self) which is
|
Slot layout (X12 005010X222A1):
|
||||||
the most common case for professional claims; the parser does not store
|
SBR01 — Payer Responsibility Sequence Number Code. Default ``"P"``
|
||||||
this on the canonical Subscriber model so we cannot thread it through
|
(Patient = primary). The parser does not capture this
|
||||||
without adding a model field.
|
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 = [
|
parts = [
|
||||||
"SBR",
|
"SBR",
|
||||||
relationship_code or "P",
|
"P", # SBR01 — primary
|
||||||
"", # SBR02 — group number
|
individual_relationship_code or "18", # SBR02 — self
|
||||||
"", # SBR03 — group name
|
"", # SBR03 — group number
|
||||||
"", # SBR04 — claim filing indicator code
|
"", # SBR04 — group name
|
||||||
"", # SBR05 — sequence number code
|
"", # SBR05 — insurance type code
|
||||||
payer_name or "", # SBR06 — claim filing indicator code (CO uses MC)
|
"", # SBR06 — coordination of benefits
|
||||||
"", # SBR07
|
"", # SBR07 — yes/no condition
|
||||||
"", # SBR08
|
"", # SBR08 — employment status code
|
||||||
member_id or "", # SBR09 — claim submitter's id
|
claim_filing_indicator_code or "", # SBR09 — claim filing indicator
|
||||||
]
|
]
|
||||||
return _ELEM.join(parts) + _SEG
|
return _ELEM.join(parts) + _SEG
|
||||||
|
|
||||||
@@ -300,7 +330,11 @@ def _build_clm(claim) -> str:
|
|||||||
clm05, # CLM05 — composite POS:qualifier:frequency_code
|
clm05, # CLM05 — composite POS:qualifier:frequency_code
|
||||||
claim.provider_signature or "Y", # CLM06
|
claim.provider_signature or "Y", # CLM06
|
||||||
claim.assignment or "Y", # CLM07
|
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
|
claim.release_of_info or "Y", # CLM09
|
||||||
]
|
]
|
||||||
return _ELEM.join(parts) + _SEG
|
return _ELEM.join(parts) + _SEG
|
||||||
@@ -325,21 +359,41 @@ def _build_lx(line_number: int) -> str:
|
|||||||
return _ELEM.join(["LX", str(line_number)]) + _SEG
|
return _ELEM.join(["LX", str(line_number)]) + _SEG
|
||||||
|
|
||||||
|
|
||||||
def _build_sv1(line) -> str:
|
def _build_sv1(line, *, dx_pointer: str | None = None) -> str:
|
||||||
"""SV1 segment — professional service line."""
|
"""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
|
proc = line.procedure
|
||||||
code = proc.code if proc else ""
|
code = proc.code if proc else ""
|
||||||
mods = proc.modifiers if proc else []
|
mods = proc.modifiers if proc else []
|
||||||
composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4])
|
composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4])
|
||||||
charge = f"{Decimal(line.charge or 0):.2f}"
|
charge = f"{Decimal(line.charge or 0):.2f}"
|
||||||
units = f"{Decimal(line.units):g}" if line.units is not None else "1"
|
units = f"{Decimal(line.units):g}" if line.units is not None else "1"
|
||||||
|
sv1_07 = dx_pointer or ""
|
||||||
parts = [
|
parts = [
|
||||||
"SV1",
|
"SV1",
|
||||||
composite,
|
composite, # SV1-01
|
||||||
charge,
|
charge, # SV1-02
|
||||||
line.unit_type or "UN",
|
line.unit_type or "UN", # SV1-03 — unit basis code
|
||||||
units,
|
units, # SV1-04
|
||||||
line.place_of_service or "",
|
line.place_of_service or "", # SV1-05
|
||||||
|
"", # SV1-06 — NOT USED in 837P
|
||||||
|
sv1_07, # SV1-07 — diagnosis pointer
|
||||||
]
|
]
|
||||||
return _ELEM.join(parts) + _SEG
|
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,
|
def _build_submitter_block(
|
||||||
contact_name: str | None,
|
sender_id: str,
|
||||||
contact_phone: str | None) -> list[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 = [
|
out = [
|
||||||
_build_nm1("41", "41", submitter_name or sender_id, "46", sender_id),
|
_build_nm1("41", "41", submitter_name or sender_id, "46", sender_id),
|
||||||
]
|
]
|
||||||
per = _build_per(contact_name, contact_phone)
|
# PER is required by X12 (at least PER01). _build_per always emits
|
||||||
if per:
|
# the segment; the submitter block always has exactly one.
|
||||||
out.append(per)
|
out.append(_build_per(contact_name, contact_phone, contact_email, email_qual))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -395,11 +454,14 @@ def _build_billing_provider_block(provider) -> list[str]:
|
|||||||
return out
|
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."""
|
"""HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
|
||||||
out = [
|
out = [
|
||||||
_build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children
|
_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(
|
_build_nm1(
|
||||||
"IL", "IL",
|
"IL", "IL",
|
||||||
f"{subscriber.last_name} {subscriber.first_name}".strip(),
|
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]:
|
def _build_service_lines_block(service_lines, *, has_diagnoses: bool = False) -> list[str]:
|
||||||
"""Per line: LX / SV1 / DTP*472 / REF*6R."""
|
"""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] = []
|
out: list[str] = []
|
||||||
for idx, line in enumerate(service_lines or [], start=1):
|
for idx, line in enumerate(service_lines or [], start=1):
|
||||||
out.append(_build_lx(idx))
|
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)
|
dtp = _build_dtp_472(line.service_date)
|
||||||
if dtp:
|
if dtp:
|
||||||
out.append(dtp)
|
out.append(dtp)
|
||||||
@@ -450,7 +524,10 @@ def serialize_837(
|
|||||||
submitter_name: str | None = None,
|
submitter_name: str | None = None,
|
||||||
submitter_contact_name: str | None = None,
|
submitter_contact_name: str | None = None,
|
||||||
submitter_contact_phone: 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,
|
receiver_name: str | None = None,
|
||||||
|
claim_filing_indicator_code: str | None = None,
|
||||||
interchange_control_number: str = "000000001",
|
interchange_control_number: str = "000000001",
|
||||||
group_control_number: str = "1",
|
group_control_number: str = "1",
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -462,6 +539,16 @@ def serialize_837(
|
|||||||
(``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass
|
(``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass
|
||||||
the configured values.
|
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
|
Editable fields (CLM, REF*G1, HI, service-line SV1, DTP*472) are
|
||||||
emitted from the canonical ``ClaimOutput`` fields, so post-parse
|
emitted from the canonical ``ClaimOutput`` fields, so post-parse
|
||||||
edits propagate to the output.
|
edits propagate to the output.
|
||||||
@@ -481,11 +568,13 @@ def serialize_837(
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
segments.extend(_build_submitter_block(
|
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_receiver_block(receiver_id, receiver_name))
|
||||||
segments.extend(_build_billing_provider_block(claim.billing_provider))
|
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))
|
segments.extend(_build_payer_block(claim.payer))
|
||||||
|
|
||||||
# Claim-level editable segments.
|
# Claim-level editable segments.
|
||||||
@@ -497,7 +586,10 @@ def serialize_837(
|
|||||||
segments.append(_build_hi(claim.diagnoses))
|
segments.append(_build_hi(claim.diagnoses))
|
||||||
|
|
||||||
# Service lines (LX / SV1 / DTP*472 / REF*6R).
|
# 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
|
# SE segment count includes ST (line 3, 1-based) through SE itself
|
||||||
# — i.e. the entire ST..SE block inclusive.
|
# — i.e. the entire ST..SE block inclusive.
|
||||||
@@ -514,15 +606,23 @@ def serialize_837_for_resubmit(
|
|||||||
claim: ClaimOutput,
|
claim: ClaimOutput,
|
||||||
*,
|
*,
|
||||||
interchange_index: int,
|
interchange_index: int,
|
||||||
|
**kwargs,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Like :func:`serialize_837` but assigns deterministic-but-unique
|
"""Like :func:`serialize_837` but assigns deterministic-but-unique
|
||||||
interchange + group control numbers for a bundle position.
|
interchange + group control numbers for a bundle position.
|
||||||
|
|
||||||
Interchange number = ``f"{interchange_index:09d}"``.
|
Interchange number = ``f"{interchange_index:09d}"``.
|
||||||
Group number = ``str(interchange_index)``.
|
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(
|
return serialize_837(
|
||||||
claim,
|
claim,
|
||||||
interchange_control_number=f"{interchange_index:09d}",
|
interchange_control_number=f"{interchange_index:09d}",
|
||||||
group_control_number=str(interchange_index),
|
group_control_number=str(interchange_index),
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
@@ -2277,7 +2277,7 @@ class CycloneStore:
|
|||||||
submitter_contact_email="tyler@dzinesco.com",
|
submitter_contact_email="tyler@dzinesco.com",
|
||||||
filename_block={
|
filename_block={
|
||||||
"tz": "America/Denver",
|
"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",
|
"inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12",
|
||||||
},
|
},
|
||||||
sftp_block={
|
sftp_block={
|
||||||
|
|||||||
@@ -24,17 +24,28 @@ def _auto_init_db(tmp_path, monkeypatch):
|
|||||||
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
|
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
|
||||||
does not invoke the FastAPI lifespan handler unless used as a context
|
does not invoke the FastAPI lifespan handler unless used as a context
|
||||||
manager. The bus is reset between tests so subscribers don't leak.
|
manager. The bus is reset between tests so subscribers don't leak.
|
||||||
|
|
||||||
|
Auth gating is enabled at the router/endpoint level via the
|
||||||
|
``Depends(matrix_gate)`` wiring in ``cyclone.api``. The auth tests
|
||||||
|
(``test_auth_*``) explicitly flip ``AUTH_DISABLED = False`` and
|
||||||
|
authenticate via the public login route to exercise the real
|
||||||
|
auth path. Every other test — the original test suite predates
|
||||||
|
auth — gets ``AUTH_DISABLED = True`` here so the existing tests
|
||||||
|
keep working without each one having to login first.
|
||||||
"""
|
"""
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||||
from cyclone import db
|
from cyclone import db
|
||||||
from cyclone.api import app
|
from cyclone.api import app
|
||||||
from cyclone.pubsub import EventBus
|
from cyclone.pubsub import EventBus
|
||||||
|
from cyclone.auth import deps
|
||||||
|
|
||||||
db._reset_for_tests()
|
db._reset_for_tests()
|
||||||
db.init_db()
|
db.init_db()
|
||||||
app.state.event_bus = EventBus()
|
app.state.event_bus = EventBus()
|
||||||
|
deps.AUTH_DISABLED = True
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
deps.AUTH_DISABLED = False
|
||||||
app.state.event_bus = None
|
app.state.event_bus = None
|
||||||
db._reset_for_tests()
|
db._reset_for_tests()
|
||||||
@@ -101,6 +101,14 @@ def test_parse_837_endpoint_streams_ndjson(client: TestClient):
|
|||||||
# Summary numbers match the JSON path.
|
# Summary numbers match the JSON path.
|
||||||
assert parsed[3]["data"]["total_claims"] == 2
|
assert parsed[3]["data"]["total_claims"] == 2
|
||||||
assert parsed[3]["data"]["passed"] == 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):
|
def test_parse_837_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ def test_parse_835_endpoint_streams_ndjson(client: TestClient):
|
|||||||
# Summary numbers match the JSON path.
|
# Summary numbers match the JSON path.
|
||||||
assert parsed[7]["data"]["total_claims"] == 2
|
assert parsed[7]["data"]["total_claims"] == 2
|
||||||
assert parsed[7]["data"]["passed"] == 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):
|
def test_parse_835_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""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(monkeypatch):
|
||||||
|
# conftest sets AUTH_DISABLED=True so pre-auth tests keep passing
|
||||||
|
# without a login. The auth-admin tests need the real auth path
|
||||||
|
# exercised, so flip it back off here.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
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
|
||||||
@@ -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,86 @@
|
|||||||
|
"""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(monkeypatch):
|
||||||
|
# conftest sets AUTH_DISABLED=True so pre-auth tests keep passing
|
||||||
|
# without a login. The auth-login-rate-limit tests need the real
|
||||||
|
# auth path exercised (the rate limiter sits in front of /login),
|
||||||
|
# so flip it back off here.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
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
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""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(monkeypatch):
|
||||||
|
# conftest sets AUTH_DISABLED=True so pre-auth tests keep passing
|
||||||
|
# without a login. The auth-route tests need the real auth path
|
||||||
|
# exercised, so flip it back off here.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
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
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""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(monkeypatch):
|
||||||
|
# conftest sets AUTH_DISABLED=True so pre-auth tests keep passing
|
||||||
|
# without a login. The auth-sessions tests need the real auth path
|
||||||
|
# exercised, so flip it back off here.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
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
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""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(monkeypatch):
|
||||||
|
# conftest sets AUTH_DISABLED=True so pre-auth tests keep passing
|
||||||
|
# without a login. The auth-users tests need the real auth path
|
||||||
|
# exercised, so flip it back off here.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
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)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Spot-check that existing endpoints now require auth (when AUTH_DISABLED is not set).
|
||||||
|
|
||||||
|
The conftest in ``tests/conftest.py`` flips ``AUTH_DISABLED=True`` for
|
||||||
|
every test module that does NOT start with ``test_auth`` — this module
|
||||||
|
deliberately is NOT named ``test_auth_*`` so it inherits the default
|
||||||
|
disabled posture... wait, that's the opposite of what we want.
|
||||||
|
|
||||||
|
This module is named ``test_existing_endpoints_require_auth`` so the
|
||||||
|
conftest will treat it as a legacy test and set ``AUTH_DISABLED=True``,
|
||||||
|
bypassing the auth check entirely. To actually verify the gate, this
|
||||||
|
test's ``client`` fixture flips ``AUTH_DISABLED`` to ``False``
|
||||||
|
*just for this test*, then restores it afterwards. This way the
|
||||||
|
rest of the suite still sees the disabled posture and existing
|
||||||
|
tests keep passing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import deps as _auth_deps
|
||||||
|
from cyclone.auth.deps import AUTH_DISABLED
|
||||||
|
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():
|
||||||
|
# Force AUTH_DISABLED=False for these tests so the dep actually checks the cookie.
|
||||||
|
original = AUTH_DISABLED
|
||||||
|
_auth_deps.AUTH_DISABLED = False
|
||||||
|
try:
|
||||||
|
yield TestClient(app)
|
||||||
|
finally:
|
||||||
|
_auth_deps.AUTH_DISABLED = original
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method,path", [
|
||||||
|
("GET", "/api/claims"),
|
||||||
|
("GET", "/api/remittances"),
|
||||||
|
("GET", "/api/providers"),
|
||||||
|
("GET", "/api/batches"),
|
||||||
|
("GET", "/api/payers/p1/summary"),
|
||||||
|
("GET", "/api/activity"),
|
||||||
|
])
|
||||||
|
def test_existing_get_endpoints_require_auth(client, method, path):
|
||||||
|
resp = client.request(method, path)
|
||||||
|
assert resp.status_code == 401, f"{method} {path} returned {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_is_public(client):
|
||||||
|
"""``/api/health`` is the public healthcheck and must remain reachable."""
|
||||||
|
resp = client.get("/api/health")
|
||||||
|
assert resp.status_code != 401, f"/api/health returned {resp.status_code}"
|
||||||
@@ -27,7 +27,8 @@ MT = ZoneInfo("America/Denver")
|
|||||||
def test_build_outbound_with_explicit_mt():
|
def test_build_outbound_with_explicit_mt():
|
||||||
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
||||||
name = build_outbound_filename("11525703", "837P", now_mt=now)
|
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():
|
def test_build_outbound_default_extension():
|
||||||
@@ -39,15 +40,17 @@ def test_build_outbound_default_extension():
|
|||||||
def test_build_outbound_custom_extension():
|
def test_build_outbound_custom_extension():
|
||||||
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
||||||
name = build_outbound_filename("11525703", "837P", ext="txt", now_mt=now)
|
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():
|
def test_build_outbound_uses_mt_when_no_arg():
|
||||||
# Snapshot test — the timestamp will be very recent; check format only
|
# Snapshot test — the timestamp will be very recent; check format only
|
||||||
name = build_outbound_filename("11525703", "837P")
|
name = build_outbound_filename("11525703", "837P")
|
||||||
assert OUTBOUND_RE.match(name), name
|
assert OUTBOUND_RE.match(name), name
|
||||||
|
# tp11525703-837P-YYYYMMDDhhmmssSSS-1of1.x12 — 4 dash-separated parts
|
||||||
parts = name.split("-")
|
parts = name.split("-")
|
||||||
assert len(parts) == 4
|
assert len(parts) == 4
|
||||||
|
assert parts[0] == "tp11525703"
|
||||||
assert len(parts[2]) == 17 # yyyymmddhhmmssSSS
|
assert len(parts[2]) == 17 # yyyymmddhhmmssSSS
|
||||||
|
|
||||||
|
|
||||||
@@ -128,8 +131,9 @@ def test_parse_inbound_rejects_non_x12_ext():
|
|||||||
|
|
||||||
|
|
||||||
def test_roundtrip_outbound_to_inbound():
|
def test_roundtrip_outbound_to_inbound():
|
||||||
# Outbound tpid is bare (no TP); inbound tpid is bare inside TP{...}
|
# Outbound uses tp{...}, inbound uses TP{...} (case differs but both
|
||||||
# The two regexes use different shapes — round-trip via tpid only.
|
# 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)
|
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
||||||
out = build_outbound_filename("11525703", "837P", now_mt=now)
|
out = build_outbound_filename("11525703", "837P", now_mt=now)
|
||||||
assert OUTBOUND_RE.match(out)
|
assert OUTBOUND_RE.match(out)
|
||||||
@@ -142,13 +146,19 @@ def test_roundtrip_outbound_to_inbound():
|
|||||||
|
|
||||||
|
|
||||||
def test_is_outbound_filename():
|
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("TP11525703-837P-20260620132243505-1of1.x12")
|
||||||
assert not is_outbound_filename("not-a-filename")
|
assert not is_outbound_filename("not-a-filename")
|
||||||
|
|
||||||
|
|
||||||
def test_is_inbound_filename():
|
def test_is_inbound_filename():
|
||||||
assert is_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.x12")
|
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")
|
assert not is_inbound_filename("11525703-837P-20260620132243505-1of1.x12")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,21 @@ def test_build_gs_emits_gs_segment_with_hc_functional_id():
|
|||||||
assert parts[6] == "1"
|
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():
|
def test_build_st_emits_837_segment():
|
||||||
st = _build_st("0001")
|
st = _build_st("0001")
|
||||||
assert st.startswith("ST*837*0001*005010X222A1~")
|
assert st.startswith("ST*837*0001*005010X222A1~")
|
||||||
@@ -100,12 +115,15 @@ def test_build_nm1_person_entity_splits_first_last():
|
|||||||
assert parts[4] == "Jane"
|
assert parts[4] == "Jane"
|
||||||
|
|
||||||
|
|
||||||
def test_build_per_returns_empty_when_no_contact():
|
def test_build_per_emits_per01_even_with_no_contact():
|
||||||
assert _build_per(None, None) == ""
|
"""PER is required by X12 Loop 1000A — at least PER01 must be present."""
|
||||||
assert _build_per("", "") == ""
|
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")
|
per = _build_per("Jane Doe", "5551234567")
|
||||||
parts = per.rstrip("~").split("*")
|
parts = per.rstrip("~").split("*")
|
||||||
assert parts[0] == "PER"
|
assert parts[0] == "PER"
|
||||||
@@ -115,6 +133,25 @@ def test_build_per_emits_segment_with_contact():
|
|||||||
assert parts[4] == "5551234567"
|
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():
|
def test_build_n3_returns_empty_when_no_address():
|
||||||
assert _build_n3(None, None) == ""
|
assert _build_n3(None, None) == ""
|
||||||
assert _build_n3("", "") == ""
|
assert _build_n3("", "") == ""
|
||||||
@@ -133,12 +170,33 @@ def test_build_hl_emits_segment():
|
|||||||
assert hl == "HL*1**20*1~"
|
assert hl == "HL*1**20*1~"
|
||||||
|
|
||||||
|
|
||||||
def test_build_sbr_emits_segment():
|
def test_build_sbr_emits_segment_with_correct_slots():
|
||||||
sbr = _build_sbr("18", "M123", "PAYER")
|
"""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("*")
|
parts = sbr.rstrip("~").split("*")
|
||||||
assert parts[0] == "SBR"
|
assert parts[0] == "SBR"
|
||||||
assert parts[1] == "18"
|
# SBR01 — primary
|
||||||
assert parts[9] == "M123"
|
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():
|
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"
|
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():
|
def test_build_ref_g1_returns_empty_when_no_prior_auth():
|
||||||
assert _build_ref_g1(None) == ""
|
assert _build_ref_g1(None) == ""
|
||||||
assert _build_ref_g1("") == ""
|
assert _build_ref_g1("") == ""
|
||||||
@@ -242,6 +328,57 @@ def test_build_sv1_emits_procedure_modifiers_charge_units():
|
|||||||
assert parts[4] == "1"
|
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():
|
def test_build_dtp_472_emits_service_date():
|
||||||
assert _build_dtp_472(date(2026, 6, 15)) == "DTP*472*D8*20260615~"
|
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)
|
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():
|
def test_serialize_error_is_an_exception():
|
||||||
assert issubclass(SerializeError, Exception)
|
assert issubclass(SerializeError, Exception)
|
||||||
|
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ def sftp_block(tmp_path):
|
|||||||
|
|
||||||
def test_stub_writes_preserving_remote_path(sftp_block, tmp_path):
|
def test_stub_writes_preserving_remote_path(sftp_block, tmp_path):
|
||||||
client = SftpClient(sftp_block)
|
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~")
|
target = client.write_file(remote, b"ISA*00*...~IEA*1*1~")
|
||||||
assert target.exists()
|
assert target.exists()
|
||||||
assert target.read_bytes() == b"ISA*00*...~IEA*1*1~"
|
assert target.read_bytes() == b"ISA*00*...~IEA*1*1~"
|
||||||
# Confirm the full nested MFT path is preserved under staging
|
# Confirm the full nested MFT path is preserved under staging
|
||||||
rel = target.relative_to(sftp_block.staging_dir)
|
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):
|
def test_stub_creates_parent_dirs(sftp_block):
|
||||||
|
|||||||
Generated
+47
-64
@@ -44,72 +44,21 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bcrypt"
|
name = "bcrypt"
|
||||||
version = "5.0.0"
|
version = "4.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
||||||
{ 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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -377,9 +326,12 @@ name = "cyclone"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "bcrypt" },
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
{ name = "cryptography" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "keyring" },
|
{ name = "keyring" },
|
||||||
|
{ name = "passlib", extra = ["bcrypt"] },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
@@ -393,6 +345,7 @@ dev = [
|
|||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pytest-randomly" },
|
||||||
]
|
]
|
||||||
sftp = [
|
sftp = [
|
||||||
{ name = "paramiko" },
|
{ name = "paramiko" },
|
||||||
@@ -403,15 +356,19 @@ sqlcipher = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "bcrypt", specifier = "<4.1" },
|
||||||
{ name = "click", specifier = ">=8.1,<9" },
|
{ name = "click", specifier = ">=8.1,<9" },
|
||||||
|
{ name = "cryptography", specifier = ">=49.0,<50" },
|
||||||
{ name = "fastapi", specifier = ">=0.110,<1" },
|
{ name = "fastapi", specifier = ">=0.110,<1" },
|
||||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27,<1" },
|
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27,<1" },
|
||||||
{ name = "keyring", specifier = ">=25.0,<26" },
|
{ name = "keyring", specifier = ">=25.0,<26" },
|
||||||
{ name = "paramiko", marker = "extra == 'sftp'", specifier = ">=3.4,<6" },
|
{ name = "paramiko", marker = "extra == 'sftp'", specifier = ">=3.4,<6" },
|
||||||
|
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
|
||||||
{ name = "pydantic", specifier = ">=2.6,<3" },
|
{ name = "pydantic", specifier = ">=2.6,<3" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23,<1" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23,<1" },
|
||||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.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 = "python-multipart", specifier = ">=0.0.9,<1" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0,<7" },
|
{ name = "pyyaml", specifier = ">=6.0,<7" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0,<3" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Cyclone — local docker-compose stack.
|
||||||
|
#
|
||||||
|
# Two services on a user-defined network:
|
||||||
|
#
|
||||||
|
# frontend (nginx) — published on http://127.0.0.1:8080
|
||||||
|
# serves the built SPA + reverse-proxies /api/* to backend
|
||||||
|
# backend (uvicorn) — NOT published externally by default; reachable from
|
||||||
|
# the frontend over the compose network on backend:8000
|
||||||
|
# (override `ports:` below to expose it for curl/debug)
|
||||||
|
#
|
||||||
|
# Persistent state:
|
||||||
|
# - `cyclone-data` named volume mounted at /data in the backend holds
|
||||||
|
# the SQLite database file. Survives `docker compose down`; only
|
||||||
|
# `docker compose down -v` wipes it.
|
||||||
|
# - `config/payers.yaml` is baked into the backend image at build time.
|
||||||
|
# To edit payer config, change the YAML, rebuild the backend image,
|
||||||
|
# and `POST /api/admin/reload-config` (or just restart the container).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose up -d --build # start (or rebuild + restart)
|
||||||
|
# docker compose logs -f # tail both services
|
||||||
|
# docker compose restart backend # bounce the backend (e.g. after crash)
|
||||||
|
# docker compose down # stop containers, KEEP the data volume
|
||||||
|
# docker compose down -v # stop AND wipe the data volume
|
||||||
|
|
||||||
|
name: cyclone
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
image: cyclone-backend:local
|
||||||
|
container_name: cyclone-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Bind on all interfaces inside the container (required — 127.0.0.1
|
||||||
|
# would only be reachable from inside the container itself).
|
||||||
|
CYCLONE_HOST: "0.0.0.0"
|
||||||
|
CYCLONE_PORT: "8000"
|
||||||
|
CYCLONE_RELOAD: "0"
|
||||||
|
# Absolute path inside the container; the named volume mounts at /data.
|
||||||
|
CYCLONE_DB_URL: "sqlite:////data/cyclone.db"
|
||||||
|
# Bootstrap admin (required on first boot unless at least one user
|
||||||
|
# already exists). The ${VAR:?msg} syntax makes docker-compose refuse
|
||||||
|
# to start with a clear error if the env var isn't set in the host
|
||||||
|
# environment.
|
||||||
|
CYCLONE_ADMIN_USERNAME: ${CYCLONE_ADMIN_USERNAME:?CYCLONE_ADMIN_USERNAME is required on first boot}
|
||||||
|
CYCLONE_ADMIN_PASSWORD: ${CYCLONE_ADMIN_PASSWORD:?CYCLONE_ADMIN_PASSWORD is required on first boot (min 12 chars)}
|
||||||
|
volumes:
|
||||||
|
- cyclone-data:/data
|
||||||
|
# The healthcheck in the Dockerfile hits /api/health. The frontend
|
||||||
|
# depends_on `service_healthy` so it won't accept traffic until the
|
||||||
|
# backend is responsive.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8000/api/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
# No `ports:` — the frontend reaches the backend over the compose
|
||||||
|
# network. Uncomment the next line to expose the API directly for
|
||||||
|
# `curl http://localhost:8000/api/...` from the host.
|
||||||
|
# ports:
|
||||||
|
# - "127.0.0.1:8000:8000"
|
||||||
|
networks:
|
||||||
|
- cyclone-net
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
image: cyclone-frontend:local
|
||||||
|
container_name: cyclone-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
# Bind address defaults to 0.0.0.0 (reachable from the LAN). Set
|
||||||
|
# CYCLONE_BIND_ADDRESS=127.0.0.1 to tighten to loopback-only and
|
||||||
|
# match the standalone install's local-only posture.
|
||||||
|
# Port defaults to 8081 to dodge the common clash on 8080; override
|
||||||
|
# with CYCLONE_WEB_PORT=... (see README).
|
||||||
|
- "${CYCLONE_BIND_ADDRESS:-0.0.0.0}:${CYCLONE_WEB_PORT:-8081}:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/healthz"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
networks:
|
||||||
|
- cyclone-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
cyclone-net:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
cyclone-data:
|
||||||
|
name: cyclone-data
|
||||||
@@ -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
@@ -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
|
||||||
+10
-1
@@ -13,6 +13,8 @@ import { Acks } from "@/pages/Acks";
|
|||||||
import { Batches } from "@/pages/Batches";
|
import { Batches } from "@/pages/Batches";
|
||||||
import { BatchDiff } from "@/pages/BatchDiff";
|
import { BatchDiff } from "@/pages/BatchDiff";
|
||||||
import Inbox from "@/pages/Inbox";
|
import Inbox from "@/pages/Inbox";
|
||||||
|
import { Login } from "@/pages/Login";
|
||||||
|
import { RequireAuth } from "@/auth/RequireAuth";
|
||||||
|
|
||||||
function NotFound() {
|
function NotFound() {
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +29,14 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<DrillStackProvider>
|
<DrillStackProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
{/* /login sits OUTSIDE the auth-gated Layout so an
|
||||||
|
unauthenticated operator can reach the sign-in screen. */}
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
{/* Every other route inherits the auth gate via RequireAuth
|
||||||
|
wrapping the Layout outlet. Authenticated operators see
|
||||||
|
the full app; unauthenticated ones are bounced back here
|
||||||
|
with `?next=<current>`. */}
|
||||||
|
<Route element={<RequireAuth><Layout /></RequireAuth>}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="claims" element={<Claims />} />
|
<Route path="claims" element={<Claims />} />
|
||||||
<Route path="remittances" element={<Remittances />} />
|
<Route path="remittances" element={<Remittances />} />
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth } from "./useAuth";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route guard. Wrap any subtree that requires a logged-in operator:
|
||||||
|
*
|
||||||
|
* <Route element={<RequireAuth><Layout/></RequireAuth>}>
|
||||||
|
* <Route path="/" element={<Dashboard />} />
|
||||||
|
* ...
|
||||||
|
* </Route>
|
||||||
|
*
|
||||||
|
* Three branches, in priority order:
|
||||||
|
*
|
||||||
|
* 1. `status === "loading"` → render a centered "Loading…"
|
||||||
|
* placeholder so we don't bounce the user to /login before the
|
||||||
|
* cookie has had a chance to prove itself via /api/auth/me.
|
||||||
|
* 2. `status === "unauthenticated"` → redirect to
|
||||||
|
* `/login?next=<current path+search>`. The Login page reads
|
||||||
|
* `next` and bounces the operator back here on success.
|
||||||
|
* 3. otherwise → render children (status is
|
||||||
|
* "authenticated").
|
||||||
|
*/
|
||||||
|
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
|
const { status } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
return (
|
||||||
|
<Navigate
|
||||||
|
to={`/login?next=${encodeURIComponent(location.pathname + location.search)}`}
|
||||||
|
replace
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 unknown as Response) as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
// 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 unknown as Response) as unknown as typeof globalThis.fetch;
|
||||||
|
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 unknown as Response) as unknown as typeof globalThis.fetch;
|
||||||
|
const { authedFetch } = await import("./api");
|
||||||
|
const data = await authedFetch("/api/anything");
|
||||||
|
expect(data).toEqual({ hello: "world" });
|
||||||
|
});
|
||||||
|
});
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `authedFetch` but returns the response body as text instead of
|
||||||
|
* parsed JSON. Used by `serializeClaim837` (the SP8 endpoint returns
|
||||||
|
* `text/x12`, not JSON). Same 401-redirect + error-shape behavior as
|
||||||
|
* the JSON variant.
|
||||||
|
*/
|
||||||
|
export async function authedFetchText(path: string, init?: RequestInit): Promise<string> {
|
||||||
|
const res = await fetch(joinUrl(path), {
|
||||||
|
credentials: "include",
|
||||||
|
...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 */
|
||||||
|
}
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
body?.error ?? "error",
|
||||||
|
body?.detail ?? res.statusText
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `authedFetch` but returns the raw `Response` so the caller can
|
||||||
|
* stream the body (NDJSON). Used by `parse837` / `parse835`. 401 still
|
||||||
|
* redirects; the caller is responsible for reading the body.
|
||||||
|
*/
|
||||||
|
export async function authedFetchResponse(path: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const res = await fetch(joinUrl(path), {
|
||||||
|
credentials: "include",
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
||||||
|
redirectToLogin();
|
||||||
|
throw new ApiError(401, "session_expired");
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -141,12 +141,14 @@ export function ClaimDrawer({
|
|||||||
// Right-anchored side panel. We override the Dialog primitive's
|
// Right-anchored side panel. We override the Dialog primitive's
|
||||||
// default centered positioning (`left-1/2 top-1/2 -translate-x-1/2
|
// 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-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.
|
// 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
|
// `aria-describedby={undefined}` suppresses the Radix warning
|
||||||
// about a missing description — the drawer content is its own
|
// about a missing description — the drawer content is its own
|
||||||
// description and there's nothing useful to point at.
|
// 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"
|
data-testid="claim-drawer"
|
||||||
aria-describedby={undefined}
|
aria-describedby={undefined}
|
||||||
>
|
>
|
||||||
@@ -167,7 +169,7 @@ export function ClaimDrawer({
|
|||||||
>
|
>
|
||||||
<ClaimDrawerHeader claim={data} onClose={onClose} />
|
<ClaimDrawerHeader claim={data} onClose={onClose} />
|
||||||
<div
|
<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"
|
data-testid="claim-drawer-tabs"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -176,15 +178,15 @@ export function ClaimDrawer({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
|
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
|
||||||
activeTab === "details"
|
activeTab === "details"
|
||||||
? "text-[color:var(--surface-ink)]"
|
? "text-foreground"
|
||||||
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
data-testid="tab-button-details"
|
data-testid="tab-button-details"
|
||||||
data-active={activeTab === "details" ? "true" : "false"}
|
data-active={activeTab === "details" ? "true" : "false"}
|
||||||
>
|
>
|
||||||
Details
|
Details
|
||||||
{activeTab === "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}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -193,29 +195,29 @@ export function ClaimDrawer({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
|
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
|
||||||
activeTab === "line-reconciliation"
|
activeTab === "line-reconciliation"
|
||||||
? "text-[color:var(--surface-ink)]"
|
? "text-foreground"
|
||||||
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
data-testid="tab-button-line-reconciliation"
|
data-testid="tab-button-line-reconciliation"
|
||||||
data-active={activeTab === "line-reconciliation" ? "true" : "false"}
|
data-active={activeTab === "line-reconciliation" ? "true" : "false"}
|
||||||
>
|
>
|
||||||
Line Reconciliation
|
Line Reconciliation
|
||||||
{activeTab === "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}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{activeTab === "line-reconciliation" ? (
|
{activeTab === "line-reconciliation" ? (
|
||||||
lr.loading ? (
|
lr.loading ? (
|
||||||
<p
|
<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"
|
data-testid="line-reconciliation-loading"
|
||||||
>
|
>
|
||||||
Loading line reconciliation…
|
Loading line reconciliation…
|
||||||
</p>
|
</p>
|
||||||
) : lr.error ? (
|
) : lr.error ? (
|
||||||
<p
|
<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"
|
data-testid="line-reconciliation-error"
|
||||||
>
|
>
|
||||||
Failed to load line reconciliation.
|
Failed to load line reconciliation.
|
||||||
@@ -224,7 +226,7 @@ export function ClaimDrawer({
|
|||||||
<LineReconciliationTab data={lr.data} />
|
<LineReconciliationTab data={lr.data} />
|
||||||
) : null
|
) : 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} />
|
<ValidationPanel validation={data.validation} />
|
||||||
<ServiceLinesTable
|
<ServiceLinesTable
|
||||||
serviceLines={data.serviceLines}
|
serviceLines={data.serviceLines}
|
||||||
|
|||||||
@@ -32,21 +32,21 @@ export function ClaimDrawerError({ kind, onRetry, onClose }: ClaimDrawerErrorPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
role="alert"
|
||||||
data-testid={`claim-drawer-error-${kind}`}
|
data-testid={`claim-drawer-error-${kind}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon
|
<Icon
|
||||||
className="h-5 w-5 text-[color:var(--m-error)]"
|
className="h-5 w-5 text-destructive"
|
||||||
strokeWidth={1.75}
|
strokeWidth={1.75}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<span className="eyebrow text-[color:var(--m-error)]">
|
<span className="eyebrow text-destructive">
|
||||||
{eyebrow}
|
{eyebrow}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
{kind === "network" && onRetry ? (
|
{kind === "network" && onRetry ? (
|
||||||
<Button variant="outline" size="sm" onClick={onRetry} data-testid="error-retry">
|
<Button variant="outline" size="sm" onClick={onRetry} data-testid="error-retry">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
export function ClaimDrawerSkeleton() {
|
export function ClaimDrawerSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
data-testid="claim-drawer-skeleton"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
|
|||||||
<section className="flex flex-col gap-3 px-6 py-4">
|
<section className="flex flex-col gap-3 px-6 py-4">
|
||||||
<h3
|
<h3
|
||||||
data-testid="section-label"
|
data-testid="section-label"
|
||||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
className="eyebrow text-muted-foreground"
|
||||||
>
|
>
|
||||||
Diagnoses ({diagnoses.length})
|
Diagnoses ({diagnoses.length})
|
||||||
</h3>
|
</h3>
|
||||||
@@ -43,7 +43,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
|
|||||||
{diagnoses.length === 0 ? (
|
{diagnoses.length === 0 ? (
|
||||||
<p
|
<p
|
||||||
data-testid="diagnoses-empty"
|
data-testid="diagnoses-empty"
|
||||||
className="text-sm text-[color:var(--m-ink-tertiary)]"
|
className="text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
No diagnoses
|
No diagnoses
|
||||||
</p>
|
</p>
|
||||||
@@ -58,17 +58,17 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
|
|||||||
className="flex items-baseline gap-3 text-sm"
|
className="flex items-baseline gap-3 text-sm"
|
||||||
>
|
>
|
||||||
<span
|
<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}
|
{d.qualifier ? `${d.qualifier} ${d.code}` : d.code}
|
||||||
</span>
|
</span>
|
||||||
{desc ? (
|
{desc ? (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
className="h-px w-3 bg-[color:var(--m-border-heavy)]/30"
|
className="h-px w-3 bg-border/60"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<span className="text-[color:var(--m-ink-secondary)] italic">
|
<span className="text-muted-foreground italic">
|
||||||
{desc}
|
{desc}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -31,26 +31,26 @@ export function LineReconciliationTab({
|
|||||||
>
|
>
|
||||||
<div className="flex gap-5 font-mono tabular-nums text-sm">
|
<div className="flex gap-5 font-mono tabular-nums text-sm">
|
||||||
<span data-testid="billed-total" className="flex flex-col gap-0.5">
|
<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
|
Billed
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[color:var(--m-ink-primary)] font-semibold">
|
<span className="text-foreground font-semibold">
|
||||||
{fmt.usdPrecise(Number(summary.billedTotal))}
|
{fmt.usdPrecise(Number(summary.billedTotal))}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span data-testid="paid-total" className="flex flex-col gap-0.5">
|
<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
|
Paid
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[color:var(--m-ink-primary)] font-semibold">
|
<span className="text-foreground font-semibold">
|
||||||
{fmt.usdPrecise(Number(summary.paidTotal))}
|
{fmt.usdPrecise(Number(summary.paidTotal))}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span data-testid="adjustments-total" className="flex flex-col gap-0.5">
|
<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
|
Adjustments
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[color:var(--m-ink-primary)] font-semibold">
|
<span className="text-foreground font-semibold">
|
||||||
{fmt.usdPrecise(Number(summary.adjustmentTotal))}
|
{fmt.usdPrecise(Number(summary.adjustmentTotal))}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -70,7 +70,7 @@ export function LineReconciliationTab({
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div data-testid="left-column">
|
<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)
|
Billed (837 SV1)
|
||||||
</h3>
|
</h3>
|
||||||
{leftRows.map((row) => (
|
{leftRows.map((row) => (
|
||||||
@@ -78,7 +78,7 @@ export function LineReconciliationTab({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div data-testid="right-column">
|
<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)
|
Adjudicated (835 SVC)
|
||||||
</h3>
|
</h3>
|
||||||
{rightRows.map((row) => (
|
{rightRows.map((row) => (
|
||||||
@@ -114,7 +114,7 @@ function LineCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
borderLeft: `3px solid ${accentColor}`,
|
borderLeft: `3px solid ${accentColor}`,
|
||||||
background: "rgba(255,255,255,0.02)",
|
background: "rgba(255,255,255,0.02)",
|
||||||
@@ -123,9 +123,9 @@ function LineCard({
|
|||||||
>
|
>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="font-mono text-sm">
|
<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 ? (
|
{lineNumber !== null ? (
|
||||||
<span className="text-[color:var(--m-ink-tertiary)] ml-2">
|
<span className="text-muted-foreground ml-2">
|
||||||
#{lineNumber}
|
#{lineNumber}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -146,16 +146,16 @@ function LineCard({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 === "left" && cl ? <span>{fmt.usdPrecise(Number(cl.charge))}</span> : null}
|
||||||
{side === "right" && svc ? <span>{fmt.usdPrecise(Number(svc.payment))}</span> : null}
|
{side === "right" && svc ? <span>{fmt.usdPrecise(Number(svc.payment))}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{row.adjustments.length > 0 ? (
|
{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) => (
|
{row.adjustments.map((a, i) => (
|
||||||
<li key={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>
|
<span className="ml-2 tabular-nums">{fmt.usdPrecise(Number(a.amount))}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -56,20 +56,20 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
|||||||
>
|
>
|
||||||
<h3
|
<h3
|
||||||
data-testid="section-label"
|
data-testid="section-label"
|
||||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
className="eyebrow text-muted-foreground"
|
||||||
>
|
>
|
||||||
Matched Remittance
|
Matched Remittance
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-testid="matched-remit-card-inner"
|
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 */}
|
{/* Left: remit id + status badge + received date */}
|
||||||
<div className="flex min-w-0 flex-col gap-2">
|
<div className="flex min-w-0 flex-col gap-2">
|
||||||
<span
|
<span
|
||||||
data-testid="matched-remit-id"
|
data-testid="matched-remit-id"
|
||||||
className="mono text-sm text-[color:var(--m-ink-primary)]"
|
className="mono text-sm text-foreground"
|
||||||
>
|
>
|
||||||
{id}
|
{id}
|
||||||
</span>
|
</span>
|
||||||
@@ -83,7 +83,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
<span
|
<span
|
||||||
data-testid="matched-remit-received"
|
data-testid="matched-remit-received"
|
||||||
className="text-xs text-[color:var(--m-ink-tertiary)]"
|
className="text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
Received {fmt.date(receivedAt)}
|
Received {fmt.date(receivedAt)}
|
||||||
</span>
|
</span>
|
||||||
@@ -94,7 +94,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
|||||||
<div className="flex flex-col items-start gap-2 sm:items-end">
|
<div className="flex flex-col items-start gap-2 sm:items-end">
|
||||||
<span
|
<span
|
||||||
data-testid="matched-remit-total-paid"
|
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)}
|
{fmt.usdPrecise(totalPaid)}
|
||||||
</span>
|
</span>
|
||||||
@@ -103,7 +103,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleViewRemittance}
|
onClick={handleViewRemittance}
|
||||||
data-testid="view-remittance-link"
|
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
|
View remittance
|
||||||
<ArrowRight
|
<ArrowRight
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function AddressBlock({ address }: { address: AddressLike }) {
|
|||||||
const a = address as ClaimDetailAddress;
|
const a = address as ClaimDetailAddress;
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
data-testid="party-address"
|
||||||
>
|
>
|
||||||
<div>{a.line1}</div>
|
<div>{a.line1}</div>
|
||||||
@@ -63,9 +63,9 @@ function PartyCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid={testId}
|
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}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{onNameClick ? (
|
{onNameClick ? (
|
||||||
@@ -74,17 +74,17 @@ function PartyCard({
|
|||||||
onClick={onNameClick}
|
onClick={onNameClick}
|
||||||
data-testid={`${testId}-name-drill`}
|
data-testid={`${testId}-name-drill`}
|
||||||
aria-label={`Drill into ${label.toLowerCase()} ${name}`}
|
aria-label={`Drill into ${label.toLowerCase()} ${name}`}
|
||||||
className="display text-lg text-[color:var(--m-ink-primary)] text-left cursor-pointer drillable rounded-sm px-0 -mx-0"
|
className="display text-lg text-foreground text-left cursor-pointer drillable rounded-sm px-0 -mx-0"
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="display text-lg text-[color:var(--m-ink-primary)]">
|
<div className="display text-lg text-foreground">
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<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}
|
{identity}
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +120,7 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
|
|||||||
>
|
>
|
||||||
<h3
|
<h3
|
||||||
data-testid="section-label"
|
data-testid="section-label"
|
||||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
className="eyebrow text-muted-foreground"
|
||||||
>
|
>
|
||||||
Parties
|
Parties
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -33,17 +33,17 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
|
|||||||
>
|
>
|
||||||
<h3
|
<h3
|
||||||
data-testid="section-label"
|
data-testid="section-label"
|
||||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
className="eyebrow text-muted-foreground"
|
||||||
>
|
>
|
||||||
Raw Segments ({rawSegments.length})
|
Raw Segments ({rawSegments.length})
|
||||||
</h3>
|
</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
|
<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"
|
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
|
{rawSegments.length === 0
|
||||||
? `Show raw segments`
|
? `Show raw segments`
|
||||||
: `Show ${rawSegments.length} raw segment${rawSegments.length === 1 ? "" : "s"}`}
|
: `Show ${rawSegments.length} raw segment${rawSegments.length === 1 ? "" : "s"}`}
|
||||||
@@ -52,14 +52,14 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
|
|||||||
{rawSegments.length === 0 ? (
|
{rawSegments.length === 0 ? (
|
||||||
<p
|
<p
|
||||||
data-testid="raw-segments-empty"
|
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
|
No segments
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<pre
|
<pre
|
||||||
data-testid="raw-segments-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)" }}
|
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||||
>
|
>
|
||||||
{rawSegments.map((segment, i) => (
|
{rawSegments.map((segment, i) => (
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function ServiceLinesTable({
|
|||||||
<section className="flex flex-col gap-3 px-6 py-4">
|
<section className="flex flex-col gap-3 px-6 py-4">
|
||||||
<h3
|
<h3
|
||||||
data-testid="section-label"
|
data-testid="section-label"
|
||||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
className="eyebrow text-muted-foreground"
|
||||||
>
|
>
|
||||||
Service Lines ({serviceLines.length})
|
Service Lines ({serviceLines.length})
|
||||||
</h3>
|
</h3>
|
||||||
@@ -57,7 +57,7 @@ export function ServiceLinesTable({
|
|||||||
{serviceLines.length === 0 ? (
|
{serviceLines.length === 0 ? (
|
||||||
<p
|
<p
|
||||||
data-testid="service-lines-empty"
|
data-testid="service-lines-empty"
|
||||||
className="text-sm text-[color:var(--m-ink-tertiary)]"
|
className="text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
No service lines
|
No service lines
|
||||||
</p>
|
</p>
|
||||||
@@ -84,34 +84,34 @@ export function ServiceLinesTable({
|
|||||||
data-line-number={line.lineNumber}
|
data-line-number={line.lineNumber}
|
||||||
className="row-hover"
|
className="row-hover"
|
||||||
>
|
>
|
||||||
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
|
<TableCell className="font-mono text-muted-foreground">
|
||||||
{line.lineNumber}
|
{line.lineNumber}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-[color:var(--m-ink-primary)]">
|
<TableCell className="font-mono text-foreground">
|
||||||
{formatProcedure(line)}
|
{formatProcedure(line)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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)" }}
|
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||||
>
|
>
|
||||||
{fmt.usdPrecise(line.charge)}
|
{fmt.usdPrecise(line.charge)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
|
<TableCell className="font-mono text-muted-foreground">
|
||||||
{formatUnits(line)}
|
{formatUnits(line)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
|
<TableCell className="font-mono text-muted-foreground">
|
||||||
{line.serviceDate ? fmt.date(line.serviceDate) : ""}
|
{line.serviceDate ? fmt.date(line.serviceDate) : ""}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
data-testid="paid-cell"
|
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)" }}
|
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||||
>
|
>
|
||||||
{formatMoneyOrDash(lr?.paid ?? null)}
|
{formatMoneyOrDash(lr?.paid ?? null)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
data-testid="adjustments-cell"
|
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)" }}
|
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||||
>
|
>
|
||||||
{formatMoneyOrDash(lr?.adjustmentsSum ?? null)}
|
{formatMoneyOrDash(lr?.adjustmentsSum ?? null)}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
|||||||
>
|
>
|
||||||
<h3
|
<h3
|
||||||
data-testid="section-label"
|
data-testid="section-label"
|
||||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
className="eyebrow text-muted-foreground"
|
||||||
>
|
>
|
||||||
State History ({history.length})
|
State History ({history.length})
|
||||||
</h3>
|
</h3>
|
||||||
@@ -72,14 +72,14 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
|||||||
{history.length === 0 ? (
|
{history.length === 0 ? (
|
||||||
<p
|
<p
|
||||||
data-testid="state-history-empty"
|
data-testid="state-history-empty"
|
||||||
className="text-sm text-[color:var(--m-ink-tertiary)]"
|
className="text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
No history events
|
No history events
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ol
|
<ol
|
||||||
data-testid="state-history-timeline"
|
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) => (
|
{history.map((event, i) => (
|
||||||
<li
|
<li
|
||||||
@@ -91,18 +91,18 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
|||||||
<span
|
<span
|
||||||
data-testid="state-history-dot"
|
data-testid="state-history-dot"
|
||||||
aria-hidden
|
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">
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
<span
|
<span
|
||||||
data-testid="state-history-kind"
|
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)}
|
{kindLabel(event.kind)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
data-testid="state-history-ts"
|
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)}
|
{fmt.date(event.ts)} · {fmt.time(event.ts)}
|
||||||
</span>
|
</span>
|
||||||
@@ -110,7 +110,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
|||||||
{event.remittanceId ? (
|
{event.remittanceId ? (
|
||||||
<span
|
<span
|
||||||
data-testid="history-remit-id"
|
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}
|
↳ Remit {event.remittanceId}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -63,12 +63,12 @@ function IssueGroup({
|
|||||||
onClick={() => openPeek({ kind: "rule", rule })}
|
onClick={() => openPeek({ kind: "rule", rule })}
|
||||||
data-testid={`${testId}-rule-drill`}
|
data-testid={`${testId}-rule-drill`}
|
||||||
aria-label={`Drill into rule ${rule}`}
|
aria-label={`Drill into rule ${rule}`}
|
||||||
className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)] cursor-pointer drillable rounded-sm px-0 -mx-0"
|
className="mono text-[12px] font-semibold tracking-tight text-foreground cursor-pointer drillable rounded-sm px-0 -mx-0"
|
||||||
>
|
>
|
||||||
{rule}
|
{rule}
|
||||||
</button>
|
</button>
|
||||||
<span
|
<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`}
|
data-testid={`${testId}-count`}
|
||||||
>
|
>
|
||||||
{issues.length}
|
{issues.length}
|
||||||
@@ -78,7 +78,7 @@ function IssueGroup({
|
|||||||
{issues.map((issue, idx) => (
|
{issues.map((issue, idx) => (
|
||||||
<li
|
<li
|
||||||
key={`${rule}-${idx}`}
|
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`}
|
data-testid={`${testId}-message`}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
@@ -121,14 +121,14 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
|||||||
className="flex items-center gap-2 px-6 py-3"
|
className="flex items-center gap-2 px-6 py-3"
|
||||||
>
|
>
|
||||||
<CheckCircle2
|
<CheckCircle2
|
||||||
className="h-4 w-4 text-[color:var(--m-success)]"
|
className="h-4 w-4 text-[hsl(var(--success))]"
|
||||||
strokeWidth={1.75}
|
strokeWidth={1.75}
|
||||||
aria-hidden
|
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
|
All checks passed
|
||||||
</span>
|
</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
|
Valid
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
@@ -140,10 +140,10 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<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"
|
data-testid="validation-panel"
|
||||||
>
|
>
|
||||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
<span className="eyebrow text-muted-foreground">
|
||||||
Validation
|
Validation
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -152,15 +152,15 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
|||||||
data-testid="validation-errors"
|
data-testid="validation-errors"
|
||||||
data-rule-group="errors"
|
data-rule-group="errors"
|
||||||
role="alert"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<AlertCircle
|
<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}
|
strokeWidth={1.75}
|
||||||
aria-hidden
|
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})
|
Errors ({validation.errors.length})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,11 +185,11 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertTriangle
|
<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}
|
strokeWidth={1.75}
|
||||||
aria-hidden
|
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})
|
Warnings ({validation.warnings.length})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,24 @@ export function Layout() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen z-10">
|
<div className="relative min-h-screen z-10">
|
||||||
<SkipLink />
|
<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
|
<div
|
||||||
className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none transition-opacity duration-200"
|
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 }}
|
style={{ opacity: showScan ? 1 : 0 }}
|
||||||
|
|||||||
+60
-13
@@ -7,6 +7,7 @@ import {
|
|||||||
Inbox as InboxIcon,
|
Inbox as InboxIcon,
|
||||||
Layers,
|
Layers,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
Receipt,
|
Receipt,
|
||||||
Stethoscope,
|
Stethoscope,
|
||||||
Upload as UploadIcon,
|
Upload as UploadIcon,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useReconciliation } from "@/hooks/useReconciliation";
|
import { useReconciliation } from "@/hooks/useReconciliation";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
|
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
|
||||||
@@ -188,25 +190,70 @@ export function Sidebar() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Operator card — always at the bottom. Ringed, mono id, the
|
{/* Operator card — always at the bottom. Ringed, mono id, the
|
||||||
account role under the name. */}
|
account role under the name. The avatar initial + username +
|
||||||
|
role all come from `useAuth()` now (replaced the hardcoded
|
||||||
|
"Jordan K. / Administrator"); the Sign out button next to it
|
||||||
|
hits `authApi.logout` and drops the operator back at /login. */}
|
||||||
<div className="px-3 py-4 border-t border-sidebar-border/80">
|
<div className="px-3 py-4 border-t border-sidebar-border/80">
|
||||||
<div className="flex items-center gap-3 rounded-md p-2 hover:bg-muted/40 cursor-default">
|
<CurrentUserCard />
|
||||||
<div className="relative h-8 w-8 rounded-full bg-accent/20 ring-1 ring-inset ring-accent/40 flex items-center justify-center text-[10.5px] font-semibold text-accent mono">
|
|
||||||
JK
|
|
||||||
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-[hsl(var(--success))] ring-2 ring-sidebar" />
|
|
||||||
</div>
|
|
||||||
<div className="leading-tight min-w-0">
|
|
||||||
<div className="text-[13px] font-medium truncate">Jordan K.</div>
|
|
||||||
<div className="mono text-[10px] text-muted-foreground tracking-wider uppercase">
|
|
||||||
Administrator
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom-of-sidebar identity card. Driven by `useAuth()` so the
|
||||||
|
* username + role update whenever the operator's session changes
|
||||||
|
* (e.g. an admin re-grants a role via the admin pages, or the
|
||||||
|
* session expires and a fresh login lands them back with a
|
||||||
|
* different username).
|
||||||
|
*/
|
||||||
|
function CurrentUserCard() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
if (!user) {
|
||||||
|
// Shouldn't happen — the route guard ensures the sidebar only
|
||||||
|
// mounts after auth is established. Render a harmless placeholder
|
||||||
|
// so the layout doesn't reflow if the context ever lags.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 rounded-md p-2 text-[12px] text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<div className="h-8 w-8 rounded-full bg-muted/40" />
|
||||||
|
<div className="leading-tight">Signed out</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Use the first letter of the username for the avatar monogram;
|
||||||
|
// fall back to "?" if the username is somehow empty.
|
||||||
|
const initial = user.username?.[0]?.toUpperCase() ?? "?";
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-md p-2 hover:bg-muted/40 group">
|
||||||
|
<div className="relative h-8 w-8 rounded-full bg-accent/20 ring-1 ring-inset ring-accent/40 flex items-center justify-center text-[10.5px] font-semibold text-accent mono shrink-0">
|
||||||
|
{initial}
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-[hsl(var(--success))] ring-2 ring-sidebar" />
|
||||||
|
</div>
|
||||||
|
<div className="leading-tight min-w-0 flex-1">
|
||||||
|
<div className="text-[13px] font-medium truncate">{user.username}</div>
|
||||||
|
<div className="mono text-[10px] text-muted-foreground tracking-wider uppercase">
|
||||||
|
{user.role}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void logout();
|
||||||
|
}}
|
||||||
|
aria-label="Sign out"
|
||||||
|
title="Sign out"
|
||||||
|
className="shrink-0 inline-flex items-center justify-center h-7 w-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring opacity-60 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<LogOut className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 mb-1.5 eyebrow text-muted-foreground/60">
|
<div className="px-3 mb-1.5 eyebrow text-muted-foreground/60">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AgingBars — AR aging stacked horizontal bar.
|
||||||
|
//
|
||||||
|
// One bar split into up to 4 segments by aging bucket (0–30 / 31–60 /
|
||||||
|
// 61–90 / 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. "0–30", "31–60", "61–90", "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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 4–8 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 4–6 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -102,10 +102,15 @@ export function Lane({
|
|||||||
<section
|
<section
|
||||||
className="flex-1 min-w-[280px] flex flex-col rounded-md overflow-hidden"
|
className="flex-1 min-w-[280px] flex flex-col rounded-md overflow-hidden"
|
||||||
style={{
|
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%)",
|
border: "1px solid hsl(220 8% 18%)",
|
||||||
boxShadow:
|
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()}
|
data-lane={name.toLowerCase()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -127,36 +127,49 @@
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
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.
|
/* A precise top-right accent + a complementary soft top-center
|
||||||
One light, not two — an instrument, not a wash. */
|
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 {
|
body::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
background: radial-gradient(
|
background:
|
||||||
55rem 35rem at 100% -15%,
|
radial-gradient(ellipse 70% 38% at 50% 0%, hsla(220, 32%, 28%, 0.16), transparent 65%),
|
||||||
hsla(212, 100%, 60%, 0.07),
|
radial-gradient(55rem 35rem at 100% -15%, hsla(212, 100%, 60%, 0.09), transparent 62%);
|
||||||
transparent 62%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hairline grid — the substrate of a precision instrument.
|
/* Hairline grid — the substrate of a precision instrument.
|
||||||
Sits behind everything, gives the dark surfaces a sense of
|
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 {
|
body::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
opacity: 0.5;
|
opacity: 0.6;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(to right, 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% 16% / 0.18) 1px, transparent 1px);
|
linear-gradient(to bottom, hsl(222 10% 18% / 0.20) 1px, transparent 1px);
|
||||||
background-size: 64px 64px;
|
background-size: 64px 64px;
|
||||||
mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, black 30%, transparent 80%);
|
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%);
|
-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
|
/* 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 {
|
.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));
|
border: 1px solid hsl(var(--border));
|
||||||
box-shadow:
|
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);
|
0 1px 2px 0 hsl(0 0% 0% / 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subtle elevated surface — used for cards that should lift a
|
/* 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 {
|
.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));
|
border: 1px solid hsl(var(--border));
|
||||||
box-shadow:
|
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);
|
0 1px 3px 0 hsl(0 0% 0% / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -217,4 +217,139 @@ describe("api GET helpers", () => {
|
|||||||
);
|
);
|
||||||
await expect(api.serializeClaim837("ghost")).rejects.toBeInstanceOf(ApiError);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+197
-166
@@ -43,6 +43,10 @@ import type {
|
|||||||
UnmatchedResponse,
|
UnmatchedResponse,
|
||||||
BatchSummary as ParserBatchSummary,
|
BatchSummary as ParserBatchSummary,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
import {
|
||||||
|
authedFetch,
|
||||||
|
authedFetchResponse,
|
||||||
|
} from "@/auth/api";
|
||||||
|
|
||||||
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
||||||
|
|
||||||
@@ -167,20 +171,28 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function joinUrl(path: string): string {
|
|
||||||
return `${BASE_URL.replace(/\/$/, "")}${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readErrorBody(res: Response): Promise<string> {
|
async function readErrorBody(res: Response): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const t = await res.text();
|
const t = await res.text();
|
||||||
if (!t) return "";
|
if (!t) return "";
|
||||||
// FastAPI errors are `{ "error": "...", "detail": "..." }`. Surface the
|
// FastAPI errors can be flat `{ "error": "...", "detail": "..." }`
|
||||||
// detail if present, else the raw text.
|
// 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 {
|
try {
|
||||||
const obj = JSON.parse(t) as { detail?: unknown; error?: unknown };
|
let current: unknown = JSON.parse(t);
|
||||||
if (typeof obj.detail === "string") return obj.detail;
|
for (let depth = 0; depth < 5; depth++) {
|
||||||
if (typeof obj.error === "string") return obj.error;
|
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 {
|
} catch {
|
||||||
// not JSON; fall through
|
// not JSON; fall through
|
||||||
}
|
}
|
||||||
@@ -284,11 +296,18 @@ async function parse837(
|
|||||||
form.append("file", file, file.name);
|
form.append("file", file, file.name);
|
||||||
|
|
||||||
const accept = onProgress ? "application/x-ndjson" : "application/json";
|
const accept = onProgress ? "application/x-ndjson" : "application/json";
|
||||||
const res = await fetch(joinUrl(`/api/parse-837?${params.toString()}`), {
|
// authedFetchResponse still hits fetch() (and still does the
|
||||||
method: "POST",
|
// 401-redirect for any non-/api/auth path), but hands back the raw
|
||||||
body: form,
|
// Response so we can stream NDJSON line-by-line below. For non-stream
|
||||||
headers: { Accept: accept },
|
// callers it returns the parsed JSON.
|
||||||
});
|
const res = await authedFetchResponse(
|
||||||
|
`/api/parse-837?${params.toString()}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
headers: { Accept: accept },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await readErrorBody(res);
|
const detail = await readErrorBody(res);
|
||||||
@@ -320,11 +339,17 @@ async function parse835(
|
|||||||
form.append("file", file, file.name);
|
form.append("file", file, file.name);
|
||||||
|
|
||||||
const accept = onProgress ? "application/x-ndjson" : "application/json";
|
const accept = onProgress ? "application/x-ndjson" : "application/json";
|
||||||
const res = await fetch(joinUrl(`/api/parse-835?${params.toString()}`), {
|
// See parse837 for why this uses authedFetchResponse instead of
|
||||||
method: "POST",
|
// authedFetch — NDJSON streaming needs the raw Response so we can
|
||||||
body: form,
|
// iterate `res.body` one chunk at a time.
|
||||||
headers: { Accept: accept },
|
const res = await authedFetchResponse(
|
||||||
});
|
`/api/parse-835?${params.toString()}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
headers: { Accept: accept },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await readErrorBody(res);
|
const detail = await readErrorBody(res);
|
||||||
@@ -350,16 +375,7 @@ async function parse999(
|
|||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file, file.name);
|
form.append("file", file, file.name);
|
||||||
const res = await fetch(joinUrl("/api/parse-999"), {
|
const body = await authedFetch<{
|
||||||
method: "POST",
|
|
||||||
body: form,
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
|
||||||
}
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
ack: {
|
ack: {
|
||||||
id: number;
|
id: number;
|
||||||
accepted_count: number;
|
accepted_count: number;
|
||||||
@@ -370,7 +386,14 @@ async function parse999(
|
|||||||
raw_999_text: string;
|
raw_999_text: string;
|
||||||
};
|
};
|
||||||
parsed: unknown;
|
parsed: unknown;
|
||||||
};
|
}>("/api/parse-999", {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
// Don't set Content-Type — the browser sets the multipart
|
||||||
|
// boundary when the body is FormData. authedFetch only adds a
|
||||||
|
// default Accept, which we override here.
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
ack: {
|
ack: {
|
||||||
id: body.ack.id,
|
id: body.ack.id,
|
||||||
@@ -391,14 +414,14 @@ async function parse999(
|
|||||||
|
|
||||||
async function health(): Promise<HealthResponse | null> {
|
async function health(): Promise<HealthResponse | null> {
|
||||||
if (!isConfigured) return null;
|
if (!isConfigured) return null;
|
||||||
const res = await fetch(joinUrl("/api/health"));
|
// health() is best-effort — used by the side-scan ping. Don't let a
|
||||||
if (!res.ok) {
|
// 401 from authedFetch redirect the operator; just return null when
|
||||||
const detail = await readErrorBody(res);
|
// anything goes wrong.
|
||||||
throw new Error(
|
try {
|
||||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
return await authedFetch<HealthResponse>("/api/health");
|
||||||
);
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return (await res.json()) as HealthResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -411,48 +434,26 @@ async function listBatches(limit?: number): Promise<BatchSummary[]> {
|
|||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const params: Record<string, unknown> = {};
|
const params: Record<string, unknown> = {};
|
||||||
if (limit !== undefined) params.limit = limit;
|
if (limit !== undefined) params.limit = limit;
|
||||||
const res = await fetch(joinUrl(`/api/batches${qs(params)}`), {
|
const body = await authedFetch<{ items: BatchSummary[] }>(
|
||||||
headers: { Accept: "application/json" },
|
`/api/batches${qs(params)}`
|
||||||
});
|
);
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new Error(
|
|
||||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const body = (await res.json()) as { items: BatchSummary[] };
|
|
||||||
return body.items;
|
return body.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBatch(id: string): Promise<ParseResult837 | ParseResult835> {
|
async function getBatch(id: string): Promise<ParseResult837 | ParseResult835> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(joinUrl(`/api/batches/${encodeURIComponent(id)}`), {
|
return authedFetch<ParseResult837 | ParseResult835>(
|
||||||
headers: { Accept: "application/json" },
|
`/api/batches/${encodeURIComponent(id)}`
|
||||||
});
|
);
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new Error(
|
|
||||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (await res.json()) as ParseResult837 | ParseResult835;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listClaims<T = unknown>(
|
async function listClaims<T = unknown>(
|
||||||
params: ListClaimsParams
|
params: ListClaimsParams
|
||||||
): Promise<PaginatedResponse<T>> {
|
): Promise<PaginatedResponse<T>> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(
|
return authedFetch<PaginatedResponse<T>>(
|
||||||
joinUrl(`/api/claims${qs(params as Record<string, unknown>)}`),
|
`/api/claims${qs(params as Record<string, unknown>)}`
|
||||||
{ headers: { Accept: "application/json" } }
|
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new Error(
|
|
||||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (await res.json()) as PaginatedResponse<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -468,14 +469,31 @@ async function listClaims<T = unknown>(
|
|||||||
*/
|
*/
|
||||||
async function getClaimDetail(id: string): Promise<ClaimDetail> {
|
async function getClaimDetail(id: string): Promise<ClaimDetail> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(joinUrl(`/api/claims/${encodeURIComponent(id)}`), {
|
try {
|
||||||
headers: { Accept: "application/json" },
|
return await authedFetch<ClaimDetail>(
|
||||||
});
|
`/api/claims/${encodeURIComponent(id)}`
|
||||||
if (!res.ok) {
|
);
|
||||||
const detail = await readErrorBody(res);
|
} catch (err) {
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
// authedFetch throws the richer auth/api.ts ApiError (carries
|
||||||
|
// `status` + `code` + `detail`). The drawer code branches on
|
||||||
|
// `.status === 404` for the "claim doesn't exist" state, and
|
||||||
|
// existing callers use `instanceof ApiError` (the lib/api.ts one)
|
||||||
|
// to detect structured failures. Re-wrap into the lib/api.ts
|
||||||
|
// ApiError so both keep working unchanged.
|
||||||
|
if (
|
||||||
|
err &&
|
||||||
|
typeof err === "object" &&
|
||||||
|
"status" in err &&
|
||||||
|
typeof (err as { status: unknown }).status === "number"
|
||||||
|
) {
|
||||||
|
const e = err as { status: number; code?: string; detail?: string };
|
||||||
|
throw new ApiError(
|
||||||
|
e.status,
|
||||||
|
e.detail ?? e.code ?? "error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
return (await res.json()) as ClaimDetail;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -497,8 +515,14 @@ async function serializeClaim837(
|
|||||||
id: string,
|
id: string,
|
||||||
): Promise<{ text: string; filename: string }> {
|
): Promise<{ text: string; filename: string }> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(
|
// authedFetchText gives us the body as a string with the same
|
||||||
joinUrl(`/api/claims/${encodeURIComponent(id)}/serialize-837`),
|
// 401-redirect + error-shape behavior as authedFetch. We need the
|
||||||
|
// Response object for the Content-Disposition header, so this
|
||||||
|
// function delegates to authedFetchResponse and reads text +
|
||||||
|
// headers itself. No Accept negotiation — the endpoint always
|
||||||
|
// returns text/x12.
|
||||||
|
const res = await authedFetchResponse(
|
||||||
|
`/api/claims/${encodeURIComponent(id)}/serialize-837`
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await readErrorBody(res);
|
const detail = await readErrorBody(res);
|
||||||
@@ -518,17 +542,9 @@ async function listRemittances<T = unknown>(
|
|||||||
params: ListRemittancesParams
|
params: ListRemittancesParams
|
||||||
): Promise<PaginatedResponse<T>> {
|
): Promise<PaginatedResponse<T>> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(
|
return authedFetch<PaginatedResponse<T>>(
|
||||||
joinUrl(`/api/remittances${qs(params as Record<string, unknown>)}`),
|
`/api/remittances${qs(params as Record<string, unknown>)}`
|
||||||
{ headers: { Accept: "application/json" } }
|
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new Error(
|
|
||||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (await res.json()) as PaginatedResponse<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -537,14 +553,7 @@ async function listRemittances<T = unknown>(
|
|||||||
*/
|
*/
|
||||||
async function getRemittance<T = unknown>(id: string): Promise<T> {
|
async function getRemittance<T = unknown>(id: string): Promise<T> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(joinUrl(`/api/remittances/${encodeURIComponent(id)}`), {
|
return authedFetch<T>(`/api/remittances/${encodeURIComponent(id)}`);
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
|
||||||
}
|
|
||||||
return (await res.json()) as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -558,34 +567,26 @@ async function getRemittance<T = unknown>(id: string): Promise<T> {
|
|||||||
*/
|
*/
|
||||||
async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
|
async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(
|
// Defense-in-depth: even though the `useBatchDiff` hook gates on both
|
||||||
joinUrl(
|
// ids being non-empty, refuse to fire the request if either is
|
||||||
`/api/batch-diff?${qs({ a, b })}`,
|
// missing. Surfaces a clear local error instead of letting the
|
||||||
),
|
// backend return its generic "Missing param" 400.
|
||||||
{ headers: { Accept: "application/json" } },
|
if (typeof a !== "string" || a.length === 0) {
|
||||||
);
|
throw new ApiError(400, "Missing param: ?a=<batch_id> is required.");
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
|
||||||
}
|
}
|
||||||
return (await res.json()) as BatchDiff;
|
if (typeof b !== "string" || b.length === 0) {
|
||||||
|
throw new ApiError(400, "Missing param: ?b=<batch_id> is required.");
|
||||||
|
}
|
||||||
|
return authedFetch<BatchDiff>(`/api/batch-diff?${qs({ a, b })}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listProviders<T = unknown>(
|
async function listProviders<T = unknown>(
|
||||||
params: ListProvidersParams = {}
|
params: ListProvidersParams = {}
|
||||||
): Promise<PaginatedResponse<T>> {
|
): Promise<PaginatedResponse<T>> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(
|
return authedFetch<PaginatedResponse<T>>(
|
||||||
joinUrl(`/api/providers${qs(params as Record<string, unknown>)}`),
|
`/api/providers${qs(params as Record<string, unknown>)}`
|
||||||
{ headers: { Accept: "application/json" } }
|
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new Error(
|
|
||||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (await res.json()) as PaginatedResponse<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -652,17 +653,9 @@ async function listActivity<T = unknown>(
|
|||||||
params: ListActivityParams = {}
|
params: ListActivityParams = {}
|
||||||
): Promise<PaginatedResponse<T>> {
|
): Promise<PaginatedResponse<T>> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(
|
return authedFetch<PaginatedResponse<T>>(
|
||||||
joinUrl(`/api/activity${qs(params as Record<string, unknown>)}`),
|
`/api/activity${qs(params as Record<string, unknown>)}`
|
||||||
{ headers: { Accept: "application/json" } }
|
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new Error(
|
|
||||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (await res.json()) as PaginatedResponse<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -673,14 +666,7 @@ async function listActivity<T = unknown>(
|
|||||||
|
|
||||||
async function listUnmatched(): Promise<UnmatchedResponse> {
|
async function listUnmatched(): Promise<UnmatchedResponse> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(joinUrl(`/api/reconciliation/unmatched`), {
|
return authedFetch<UnmatchedResponse>(`/api/reconciliation/unmatched`);
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
|
||||||
}
|
|
||||||
return (await res.json()) as UnmatchedResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function matchRemit(
|
async function matchRemit(
|
||||||
@@ -688,36 +674,20 @@ async function matchRemit(
|
|||||||
remitId: string
|
remitId: string
|
||||||
): Promise<MatchResponse> {
|
): Promise<MatchResponse> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(joinUrl(`/api/reconciliation/match`), {
|
return authedFetch<MatchResponse>(`/api/reconciliation/match`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ claim_id: claimId, remit_id: remitId }),
|
body: JSON.stringify({ claim_id: claimId, remit_id: remitId }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
|
||||||
}
|
|
||||||
return (await res.json()) as MatchResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unmatchClaim(claimId: string): Promise<{ claim: UnmatchedClaim }> {
|
async function unmatchClaim(claimId: string): Promise<{ claim: UnmatchedClaim }> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(joinUrl(`/api/reconciliation/unmatch`), {
|
return authedFetch<{ claim: UnmatchedClaim }>(`/api/reconciliation/unmatch`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ claim_id: claimId }),
|
body: JSON.stringify({ claim_id: claimId }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await readErrorBody(res);
|
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
|
||||||
}
|
|
||||||
return (await res.json()) as { claim: UnmatchedClaim };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -756,15 +726,12 @@ async function listAcks(params: { limit?: number } = {}): Promise<PaginatedRespo
|
|||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const query: Record<string, unknown> = {};
|
const query: Record<string, unknown> = {};
|
||||||
if (params.limit !== undefined) query.limit = params.limit;
|
if (params.limit !== undefined) query.limit = params.limit;
|
||||||
const res = await fetch(
|
const body = await authedFetch<{
|
||||||
joinUrl(`/api/acks${qs(query)}`),
|
items: RawAckRow[];
|
||||||
{ headers: { Accept: "application/json" } }
|
total: number;
|
||||||
);
|
returned: number;
|
||||||
if (!res.ok) {
|
has_more: boolean;
|
||||||
const detail = await readErrorBody(res);
|
}>(`/api/acks${qs(query)}`);
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
|
||||||
}
|
|
||||||
const body = (await res.json()) as { items: RawAckRow[]; total: number; returned: number; has_more: boolean };
|
|
||||||
return {
|
return {
|
||||||
items: body.items.map(mapAck),
|
items: body.items.map(mapAck),
|
||||||
total: body.total,
|
total: body.total,
|
||||||
@@ -775,15 +742,78 @@ async function listAcks(params: { limit?: number } = {}): Promise<PaginatedRespo
|
|||||||
|
|
||||||
async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
|
async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
|
||||||
if (!isConfigured) throw notConfiguredError();
|
if (!isConfigured) throw notConfiguredError();
|
||||||
const res = await fetch(joinUrl(`/api/acks/${encodeURIComponent(String(id))}`), {
|
const row = await authedFetch<RawAckRow & { raw_json: unknown }>(
|
||||||
headers: { Accept: "application/json" },
|
`/api/acks/${encodeURIComponent(String(id))}`
|
||||||
});
|
);
|
||||||
|
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) {
|
if (!res.ok) {
|
||||||
const detail = await readErrorBody(res);
|
const detail = await readErrorBody(res);
|
||||||
throw new ApiError(res.status, detail || res.statusText);
|
throw new ApiError(res.status, detail || res.statusText);
|
||||||
}
|
}
|
||||||
const row = (await res.json()) as RawAckRow & { raw_json: unknown };
|
const blob = await res.blob();
|
||||||
return { ...mapAck(row), rawJson: row.raw_json };
|
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 = {
|
export const api = {
|
||||||
@@ -799,6 +829,7 @@ export const api = {
|
|||||||
listClaims,
|
listClaims,
|
||||||
getClaimDetail,
|
getClaimDetail,
|
||||||
serializeClaim837,
|
serializeClaim837,
|
||||||
|
exportBatch837,
|
||||||
listRemittances,
|
listRemittances,
|
||||||
getRemittance,
|
getRemittance,
|
||||||
listProviders,
|
listProviders,
|
||||||
|
|||||||
+8
-1
@@ -2,6 +2,7 @@ import React from "react";
|
|||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { AuthProvider } from "@/auth/AuthProvider";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
@@ -18,7 +19,13 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
{/* AuthProvider must sit inside BrowserRouter (so it can use
|
||||||
|
react-router hooks) but outside the route tree (so the
|
||||||
|
`/login` route still has access to auth state for the
|
||||||
|
auto-redirect when the operator is already signed in). */}
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
|
|||||||
+392
-703
File diff suppressed because it is too large
Load Diff
+350
-573
File diff suppressed because it is too large
Load Diff
+159
-491
@@ -4,7 +4,9 @@ import { Files, GitBranch, Layers, Receipt } from "lucide-react";
|
|||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
import { ErrorState } from "@/components/ui/error-state";
|
import { ErrorState } from "@/components/ui/error-state";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { KpiCard } from "@/components/KpiCard";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
import { BatchesList, BatchesListSkeleton } from "@/components/BatchesList";
|
import { BatchesList, BatchesListSkeleton } from "@/components/BatchesList";
|
||||||
import {
|
import {
|
||||||
BatchDetailContent,
|
BatchDetailContent,
|
||||||
@@ -16,6 +18,7 @@ import { useBatches } from "@/hooks/useBatches";
|
|||||||
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
|
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
|
||||||
import { api, ApiError, type BatchSummary } from "@/lib/api";
|
import { api, ApiError, type BatchSummary } from "@/lib/api";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { fmt } from "@/lib/format";
|
||||||
import type { ParseResult837, ParseResult835 } from "@/types";
|
import type { ParseResult837, ParseResult835 } from "@/types";
|
||||||
|
|
||||||
function readBatchId(): string | null {
|
function readBatchId(): string | null {
|
||||||
@@ -120,12 +123,16 @@ function useBatchDetail(id: string | null): {
|
|||||||
const HELP_NOOP = () => {};
|
const HELP_NOOP = () => {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batches page — hybrid Magazine Spread treatment.
|
* Batches page — dark instrument treatment, in the same chrome
|
||||||
|
* language as Dashboard / Upload / Reconciliation / Acks.
|
||||||
*
|
*
|
||||||
* Dark editorial hero → torn-page fold → cream paper plane with the
|
* PageHeader hero (eyebrow + display title + status pill + ghost
|
||||||
* batch register (KPI strip + tabular list). Click a row to open the
|
* watermark) → KpiCard strip (4 tiles) → single Card wrapping the
|
||||||
* detail drawer (rendered above the page, dimmed via the body
|
* tabular register → quiet hairline footer.
|
||||||
* opacity).
|
*
|
||||||
|
* Click a row to open the detail drawer (rendered above the page,
|
||||||
|
* dimmed via the body opacity). Keyboard: ← / → step through
|
||||||
|
* batches while the drawer is open; Esc closes.
|
||||||
*/
|
*/
|
||||||
export function Batches() {
|
export function Batches() {
|
||||||
const { data, isLoading, isError, error, refetch } = useBatches(100);
|
const { data, isLoading, isError, error, refetch } = useBatches(100);
|
||||||
@@ -166,7 +173,7 @@ export function Batches() {
|
|||||||
const errKind = batchErrorKind(detailError);
|
const errKind = batchErrorKind(detailError);
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Aggregate metrics for the § 01 KPI strip
|
// Aggregate metrics for the KPI strip + hero copy.
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
const totals = items.reduce(
|
const totals = items.reduce(
|
||||||
(acc, b) => ({
|
(acc, b) => ({
|
||||||
@@ -178,14 +185,20 @@ export function Batches() {
|
|||||||
{ count: 0, p837: 0, p835: 0, claims: 0 },
|
{ count: 0, p837: 0, p835: 0, claims: 0 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const lastParsed = items[0]?.parsedAt;
|
||||||
|
|
||||||
|
// The ghost watermark carries the on-file count, scaled like the
|
||||||
|
// other pages (clamp 72–140px, 4.5% opacity, right-anchored).
|
||||||
|
const watermark = totals.count > 0 ? totals.count.toLocaleString() : "BATCH";
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Stagger choreography (matches Acks / Reconciliation / Upload)
|
// Stagger choreography — hero lands first, KPI strip at 140ms,
|
||||||
|
// register at 240ms, footer at 320ms. Total < 500ms.
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
const heroDelay = 0;
|
const heroDelay = 0;
|
||||||
const foldDelay = 220;
|
const kpiDelay = 140;
|
||||||
const titleDelay = 320;
|
const tableDelay = 240;
|
||||||
const kpiDelay = 460;
|
const footerDelay = 320;
|
||||||
const tableDelay = 600;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -219,541 +232,196 @@ export function Batches() {
|
|||||||
<div
|
<div
|
||||||
data-testid="batches-page-body"
|
data-testid="batches-page-body"
|
||||||
className={cn(
|
className={cn(
|
||||||
"space-y-0 animate-fade-in transition-opacity",
|
"space-y-6 lg:space-y-10 animate-fade-in transition-opacity",
|
||||||
dimBackground && "pointer-events-none opacity-60",
|
dimBackground && "pointer-events-none opacity-60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* =================================================================
|
{/* =================================================================
|
||||||
HERO — DARK EDITORIAL HEADER
|
HERO — dark editorial header
|
||||||
================================================================= */}
|
================================================================= */}
|
||||||
<section
|
<section
|
||||||
className="relative pt-6 pb-8 lg:pt-9 lg:pb-10"
|
className="relative animate-fade-in overflow-hidden"
|
||||||
style={{ animationDelay: `${heroDelay}ms` }}
|
style={{ animationDelay: `${heroDelay}ms` }}
|
||||||
>
|
>
|
||||||
{/* Ghost "PARSED" watermark — a print-shop stamp behind the title. */}
|
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none select-none absolute inset-x-0 top-[58%] -translate-y-1/2 whitespace-nowrap display text-center"
|
className="pointer-events-none select-none absolute -right-4 top-1/2 -translate-y-1/2 whitespace-nowrap display text-foreground"
|
||||||
style={{
|
style={{
|
||||||
fontSize: "clamp(160px, 20vw, 300px)",
|
fontSize: "clamp(72px, 12vw, 140px)",
|
||||||
letterSpacing: "-0.05em",
|
letterSpacing: "-0.04em",
|
||||||
opacity: 0.05,
|
opacity: 0.045,
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
color: "hsl(var(--surface-ink))",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
PARSED
|
{watermark}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-8 items-end mb-7">
|
<div className="relative z-10 flex items-end justify-between gap-4 sm:gap-6 flex-wrap">
|
||||||
<div className="min-w-0 max-w-3xl">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-5">
|
<PageHeader
|
||||||
<div className="h-px w-14 bg-foreground/25" />
|
eyebrow={`Batches · Register · ${totals.count} on file`}
|
||||||
<span className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground">
|
title={
|
||||||
Batches · Register
|
<>
|
||||||
</span>
|
Parsed{" "}
|
||||||
<span
|
<span className="italic text-muted-foreground/85">
|
||||||
className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground/60 hidden sm:inline"
|
batches.
|
||||||
aria-hidden
|
</span>
|
||||||
>
|
</>
|
||||||
· {totals.count} on file
|
}
|
||||||
</span>
|
subtitle={
|
||||||
</div>
|
<>
|
||||||
<h1 className="display text-[48px] sm:text-[64px] lg:text-[80px] leading-[0.92] text-foreground tracking-[-0.04em]">
|
Every 837P and 835 file the backend has ingested. Click a
|
||||||
Parsed{" "}
|
row for envelope, summary, and a peek at the contained
|
||||||
<span className="italic text-muted-foreground/85">batches.</span>
|
claims.
|
||||||
</h1>
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-start lg:items-end gap-3">
|
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3 py-1.5 text-[11px] mono uppercase tracking-[0.12em] text-muted-foreground backdrop-blur">
|
||||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3.5 py-2 text-[11.5px] mono uppercase tracking-[0.14em] text-muted-foreground backdrop-blur">
|
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[hsl(var(--accent))]">
|
||||||
<Files
|
<span className="absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--accent))] opacity-60 animate-ping" />
|
||||||
className="h-3.5 w-3.5"
|
</span>
|
||||||
strokeWidth={1.75}
|
<Files className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||||
style={{ color: "hsl(var(--accent))" }}
|
837P · 835 · envelope & summary
|
||||||
/>
|
|
||||||
837P · 835 · envelope & summary
|
|
||||||
</div>
|
|
||||||
<kbd
|
|
||||||
className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70"
|
|
||||||
>
|
|
||||||
Tap a row to open the drawer
|
|
||||||
</kbd>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 max-w-2xl">
|
|
||||||
<p className="text-[14px] text-muted-foreground leading-relaxed">
|
|
||||||
Every 837P and 835 file the backend has ingested. Click a row
|
|
||||||
for envelope, summary, and a peek at the contained claims.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* =================================================================
|
{/* =================================================================
|
||||||
FOLD — TORN PAGE
|
KPI STRIP — four standard KpiCards, accent tokens instead of
|
||||||
|
paper-tinted custom tiles.
|
||||||
================================================================= */}
|
================================================================= */}
|
||||||
<div
|
<section
|
||||||
aria-hidden
|
aria-label="Batch register metrics"
|
||||||
className="relative h-14"
|
className="grid gap-3.5 grid-cols-2 md:grid-cols-4 animate-fade-in-up"
|
||||||
style={{ animationDelay: `${foldDelay}ms` }}
|
style={{ animationDelay: `${kpiDelay}ms` }}
|
||||||
>
|
>
|
||||||
<div
|
<KpiCard
|
||||||
className="absolute inset-x-0 top-0 h-1/2"
|
label="On file"
|
||||||
style={{
|
value={fmt.num(totals.count)}
|
||||||
background:
|
icon={Layers}
|
||||||
"linear-gradient(to bottom, hsl(0 0% 0% / 0.55), transparent)",
|
accent="default"
|
||||||
}}
|
hint={totals.count === 1 ? "batch" : "batches"}
|
||||||
/>
|
/>
|
||||||
<div
|
<KpiCard
|
||||||
className="absolute inset-x-0 bottom-0 h-1/2"
|
label="837P"
|
||||||
style={{
|
value={fmt.num(totals.p837)}
|
||||||
background:
|
icon={GitBranch}
|
||||||
"linear-gradient(to top, hsl(30 14% 14% / 0.18), transparent)",
|
accent="accent"
|
||||||
}}
|
hint="professional claims"
|
||||||
/>
|
/>
|
||||||
<div
|
<KpiCard
|
||||||
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
|
label="835"
|
||||||
style={{
|
value={fmt.num(totals.p835)}
|
||||||
background:
|
icon={Receipt}
|
||||||
"linear-gradient(to right, transparent, hsl(30 14% 14% / 0.5) 12%, hsl(30 14% 14% / 0.7) 50%, hsl(30 14% 14% / 0.5) 88%, transparent)",
|
accent="warning"
|
||||||
}}
|
hint="remittance advice"
|
||||||
/>
|
/>
|
||||||
<div
|
<KpiCard
|
||||||
className="absolute inset-x-0 top-1/2 -translate-y-1/2 flex justify-between px-2"
|
label="Claims"
|
||||||
style={{ pointerEvents: "none" }}
|
value={fmt.num(totals.claims)}
|
||||||
>
|
accent="success"
|
||||||
{Array.from({ length: 48 }, (_, i) => (
|
hint="total in register"
|
||||||
<span
|
/>
|
||||||
key={i}
|
</section>
|
||||||
className="block h-[3px] w-[3px] rounded-full"
|
|
||||||
style={{ backgroundColor: "hsl(30 14% 14% / 0.22)" }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10 mx-auto h-full flex items-center justify-center gap-3 bg-background px-4 w-fit">
|
|
||||||
<span
|
|
||||||
className="display italic"
|
|
||||||
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
|
|
||||||
>
|
|
||||||
↘
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="mono uppercase tracking-[0.24em]"
|
|
||||||
style={{
|
|
||||||
color: "hsl(var(--muted-foreground))",
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
The register
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="display italic"
|
|
||||||
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
|
|
||||||
>
|
|
||||||
↙
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* =================================================================
|
{/* =================================================================
|
||||||
PAPER PLANE
|
REGISTER — single Card wrapping the tabular list. Same
|
||||||
|
chrome language as the rest of the app: eyebrow + display
|
||||||
|
title + sidecar hint, dark-tone table.
|
||||||
================================================================= */}
|
================================================================= */}
|
||||||
<div
|
<section
|
||||||
className="relative max-w-[1280px] mx-auto"
|
aria-label="Batch register"
|
||||||
style={{
|
className="animate-fade-in-up"
|
||||||
animationDelay: `${titleDelay}ms`,
|
style={{ animationDelay: `${tableDelay}ms` }}
|
||||||
backgroundColor: "hsl(var(--surface))",
|
|
||||||
boxShadow:
|
|
||||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 30px 80px -24px hsl(0 0% 0% / 0.45)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Paper grain */}
|
<Card>
|
||||||
<div
|
<CardContent className="p-6 lg:p-7 space-y-5">
|
||||||
aria-hidden
|
<div className="flex items-end justify-between gap-6 flex-wrap">
|
||||||
className="pointer-events-none absolute inset-0 opacity-[0.04]"
|
<div className="min-w-0">
|
||||||
style={{
|
<div className="eyebrow flex items-center gap-2 mb-2">
|
||||||
backgroundImage:
|
<span className="inline-block h-px w-6 bg-foreground/20" />
|
||||||
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.08 0 0 0 0 0.05 0 0 0 0.9 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\")",
|
|
||||||
backgroundSize: "160px 160px",
|
|
||||||
mixBlendMode: "multiply",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Title block */}
|
|
||||||
<div
|
|
||||||
className="relative px-8 lg:px-14 pt-12 pb-9 border-b animate-fade-in-up"
|
|
||||||
style={{
|
|
||||||
animationDelay: `${titleDelay}ms`,
|
|
||||||
borderColor: "hsl(30 14% 14% / 0.12)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="absolute left-7 lg:left-12 top-0 bottom-0 w-px"
|
|
||||||
style={{ backgroundColor: "hsl(30 14% 14% / 0.14)" }}
|
|
||||||
/>
|
|
||||||
<div className="flex items-end justify-between gap-8 flex-wrap">
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="mono text-[12px] uppercase tracking-[0.24em] mb-4 font-medium"
|
|
||||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
||||||
>
|
|
||||||
Register · Parsed batches
|
|
||||||
</div>
|
|
||||||
<h2
|
|
||||||
className="display leading-[0.92] tracking-[-0.04em]"
|
|
||||||
style={{
|
|
||||||
color: "hsl(var(--surface-ink))",
|
|
||||||
fontSize: "clamp(48px, 7vw, 96px)",
|
|
||||||
fontWeight: 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
The register.
|
|
||||||
</h2>
|
|
||||||
<div
|
|
||||||
className="mt-4 display italic"
|
|
||||||
style={{
|
|
||||||
color: "hsl(var(--surface-ink-2))",
|
|
||||||
fontSize: "clamp(15px, 1.3vw, 18px)",
|
|
||||||
lineHeight: 1.4,
|
|
||||||
maxWidth: "32ch",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
One row per ingested file — the kind, the input filename,
|
|
||||||
how many claims it carried, and the day it was parsed.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right shrink-0">
|
|
||||||
<div
|
|
||||||
className="mono text-[11px] uppercase tracking-[0.24em] mb-1.5 font-medium"
|
|
||||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
||||||
>
|
|
||||||
On file
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="display tabular-nums"
|
|
||||||
style={{
|
|
||||||
color: "hsl(var(--surface-ink))",
|
|
||||||
fontSize: 26,
|
|
||||||
lineHeight: 1.1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{totals.count}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="mono text-[11px] mt-1"
|
|
||||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
||||||
>
|
|
||||||
batch{totals.count === 1 ? "" : "es"} · {totals.claims}{" "}
|
|
||||||
claim{totals.claims === 1 ? "" : "s"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* KPI strip — § 01 Vital signs */}
|
|
||||||
<section
|
|
||||||
aria-label="Batch register metrics"
|
|
||||||
className="relative px-8 lg:px-14 py-7 animate-fade-in-up"
|
|
||||||
style={{ animationDelay: `${kpiDelay}ms` }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="absolute left-7 lg:left-12 top-7 flex flex-col items-center gap-1"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
|
|
||||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
||||||
>
|
|
||||||
§ 01
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="w-px h-10"
|
|
||||||
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="display italic"
|
|
||||||
style={{
|
|
||||||
color: "hsl(var(--surface-ink-2))",
|
|
||||||
fontSize: 11,
|
|
||||||
writingMode: "vertical-rl",
|
|
||||||
transform: "rotate(180deg)",
|
|
||||||
letterSpacing: "0.16em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Vital signs
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<BatchesKpiTile
|
|
||||||
label="On file"
|
|
||||||
value={totals.count}
|
|
||||||
icon={<Layers className="h-3 w-3" strokeWidth={1.75} />}
|
|
||||||
tone="ink"
|
|
||||||
/>
|
|
||||||
<BatchesKpiTile
|
|
||||||
label="837P"
|
|
||||||
value={totals.p837}
|
|
||||||
icon={<GitBranch className="h-3 w-3" strokeWidth={1.75} />}
|
|
||||||
tone="blue"
|
|
||||||
/>
|
|
||||||
<BatchesKpiTile
|
|
||||||
label="835"
|
|
||||||
value={totals.p835}
|
|
||||||
icon={<Receipt className="h-3 w-3" strokeWidth={1.75} />}
|
|
||||||
tone="amber"
|
|
||||||
/>
|
|
||||||
<BatchesKpiTile
|
|
||||||
label="Claims"
|
|
||||||
value={totals.claims}
|
|
||||||
tone="success"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Table — § 02 The register */}
|
|
||||||
<section
|
|
||||||
aria-label="Batch register"
|
|
||||||
className="relative px-8 lg:px-14 pb-8 lg:pb-10 animate-fade-in-up"
|
|
||||||
style={{ animationDelay: `${tableDelay}ms` }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="absolute left-7 lg:left-12 top-9 flex flex-col items-center gap-1"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
|
|
||||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
||||||
>
|
|
||||||
§ 02
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="w-px h-12"
|
|
||||||
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="display italic"
|
|
||||||
style={{
|
|
||||||
color: "hsl(var(--surface-ink-2))",
|
|
||||||
fontSize: 11,
|
|
||||||
writingMode: "vertical-rl",
|
|
||||||
transform: "rotate(180deg)",
|
|
||||||
letterSpacing: "0.16em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
The register
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="pt-6 border-t"
|
|
||||||
style={{
|
|
||||||
borderTopStyle: "double",
|
|
||||||
borderTopWidth: 3,
|
|
||||||
borderColor: "hsl(30 14% 14% / 0.12)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-end justify-between gap-6 flex-wrap mb-5">
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="mono text-[11.5px] uppercase tracking-[0.24em] font-semibold mb-2"
|
|
||||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
||||||
>
|
|
||||||
The register
|
The register
|
||||||
</div>
|
</div>
|
||||||
<h3
|
<h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
|
||||||
className="display leading-[0.98] tracking-[-0.03em]"
|
|
||||||
style={{
|
|
||||||
color: "hsl(var(--surface-ink))",
|
|
||||||
fontSize: "clamp(24px, 2.6vw, 32px)",
|
|
||||||
fontWeight: 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
All <span className="italic">ingests</span>, newest first.
|
All <span className="italic">ingests</span>, newest first.
|
||||||
</h3>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
|
||||||
className="text-[12.5px] display italic"
|
|
||||||
style={{ color: "hsl(var(--surface-ink-2))", maxWidth: 380 }}
|
|
||||||
>
|
|
||||||
Tap a row to open the envelope drawer. Use{" "}
|
Tap a row to open the envelope drawer. Use{" "}
|
||||||
<kbd
|
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80 mono text-[10.5px] uppercase tracking-[0.14em]">
|
||||||
className="mono not-italic text-[10.5px] uppercase tracking-[0.14em] px-1 py-0.5 rounded-sm border"
|
|
||||||
style={{
|
|
||||||
borderColor: "hsl(30 14% 14% / 0.20)",
|
|
||||||
color: "hsl(var(--surface-ink))",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
←
|
←
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
/{" "}
|
/{" "}
|
||||||
<kbd
|
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80 mono text-[10.5px] uppercase tracking-[0.14em]">
|
||||||
className="mono not-italic text-[10.5px] uppercase tracking-[0.14em] px-1 py-0.5 rounded-sm border"
|
|
||||||
style={{
|
|
||||||
borderColor: "hsl(30 14% 14% / 0.20)",
|
|
||||||
color: "hsl(var(--surface-ink))",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
→
|
→
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
inside the drawer to step.
|
inside the drawer to step.
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isError ? (
|
<div className="pt-5 border-t border-border/40">
|
||||||
<div
|
{isError ? (
|
||||||
className="rounded-md border p-5"
|
|
||||||
style={{
|
|
||||||
borderColor: "hsl(30 14% 14% / 0.16)",
|
|
||||||
backgroundColor: "hsl(36 22% 96%)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message="Couldn't load batches from the backend."
|
message="Couldn't load batches from the backend."
|
||||||
detail={error instanceof Error ? error.message : String(error)}
|
detail={error instanceof Error ? error.message : String(error)}
|
||||||
onRetry={() => refetch()}
|
onRetry={() => refetch()}
|
||||||
/>
|
/>
|
||||||
</div>
|
) : isLoading ? (
|
||||||
) : isLoading ? (
|
|
||||||
<div
|
|
||||||
className="rounded-md border p-4 space-y-2"
|
|
||||||
style={{
|
|
||||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
|
||||||
backgroundColor: "hsl(36 22% 96%)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BatchesListSkeleton />
|
<BatchesListSkeleton />
|
||||||
</div>
|
) : items.length === 0 ? (
|
||||||
) : items.length === 0 ? (
|
<div className="rounded-md border border-dashed border-border/60 bg-card/40">
|
||||||
<div
|
<EmptyState
|
||||||
className="rounded-md border p-10 text-center"
|
eyebrow="Batches · awaiting first ingest"
|
||||||
style={{
|
message="Upload an 837P or 835 file on the Upload page to populate this list."
|
||||||
borderColor: "hsl(30 14% 14% / 0.16)",
|
/>
|
||||||
borderStyle: "dashed",
|
</div>
|
||||||
backgroundColor: "hsl(36 22% 96%)",
|
) : (
|
||||||
}}
|
<div className="rounded-md border border-border/60 overflow-hidden bg-card/40">
|
||||||
>
|
<BatchesList
|
||||||
<EmptyState
|
items={items}
|
||||||
eyebrow="Batches · awaiting first ingest"
|
openId={batchId}
|
||||||
message="Upload an 837P or 835 file on the Upload page to populate this list."
|
onOpen={open}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<div
|
</div>
|
||||||
className="rounded-md border overflow-hidden"
|
</CardContent>
|
||||||
style={{
|
</Card>
|
||||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
</section>
|
||||||
backgroundColor: "hsl(36 22% 98%)",
|
|
||||||
boxShadow:
|
|
||||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BatchesList
|
|
||||||
items={items}
|
|
||||||
openId={batchId}
|
|
||||||
onOpen={open}
|
|
||||||
tone="paper"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer */}
|
{/* =================================================================
|
||||||
<div
|
FOOTER — single hairline-separated status row. Replaces the
|
||||||
className="relative px-8 lg:px-14 py-5 border-t flex items-center justify-between mono text-[10.5px] uppercase tracking-[0.18em]"
|
warm-paper "End of register" treatment with a quiet
|
||||||
style={{
|
instrument-style status line in the same rhythm as the
|
||||||
borderColor: "hsl(30 14% 14% / 0.12)",
|
other pages.
|
||||||
color: "hsl(var(--surface-ink-3))",
|
================================================================= */}
|
||||||
}}
|
<section
|
||||||
>
|
aria-label="Register status"
|
||||||
<span>End of register</span>
|
className="animate-fade-in-up flex items-center justify-between gap-4 flex-wrap pt-5 border-t border-border/40"
|
||||||
<span>Cyclone · Batches</span>
|
style={{ animationDelay: `${footerDelay}ms` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-[hsl(var(--accent))]" />
|
||||||
|
Cyclone · Batches
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
|
||||||
<span>
|
<span>
|
||||||
{totals.count} {totals.count === 1 ? "row" : "rows"}
|
{totals.count} {totals.count === 1 ? "row" : "rows"}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-muted-foreground/30" aria-hidden>
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span>last parsed {lastParsed ? fmt.dateShort(lastParsed) : "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
|
||||||
|
<span>open drawer</span>
|
||||||
|
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80">
|
||||||
|
↩
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// BatchesKpiTile — paper-toned metric for the batch register.
|
|
||||||
// Mirrors AckKpiTile / KpiTile in the other hybrid pages.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function BatchesKpiTile({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
tone,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
tone: "blue" | "amber" | "ink" | "success";
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const accentMap = {
|
|
||||||
blue: "hsl(212 100% 45%)",
|
|
||||||
amber: "hsl(36 92% 50%)",
|
|
||||||
ink: "hsl(var(--surface-ink))",
|
|
||||||
success: "hsl(152 64% 38%)",
|
|
||||||
} as const;
|
|
||||||
const tintMap = {
|
|
||||||
blue: "hsl(212 85% 92%)",
|
|
||||||
amber: "hsl(36 82% 92%)",
|
|
||||||
ink: "hsl(36 22% 90%)",
|
|
||||||
success: "hsl(152 50% 88%)",
|
|
||||||
} as const;
|
|
||||||
const accent = accentMap[tone];
|
|
||||||
const tint = tintMap[tone];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="relative rounded-xl p-5 overflow-hidden border"
|
|
||||||
style={{
|
|
||||||
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)",
|
|
||||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="absolute left-0 top-5 bottom-5 w-[3px] rounded-r-sm"
|
|
||||||
style={{ backgroundColor: accent, opacity: 0.85 }}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between mb-2.5">
|
|
||||||
<div
|
|
||||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
|
|
||||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="h-5 w-5 rounded-md flex items-center justify-center"
|
|
||||||
style={{ backgroundColor: tint, color: accent }}
|
|
||||||
>
|
|
||||||
{icon ?? (
|
|
||||||
<span
|
|
||||||
className="block h-1.5 w-1.5 rounded-full"
|
|
||||||
style={{ backgroundColor: accent }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="display tabular-nums tracking-[-0.04em]"
|
|
||||||
style={{
|
|
||||||
color: "hsl(var(--surface-ink))",
|
|
||||||
fontSize: "clamp(28px, 3vw, 40px)",
|
|
||||||
lineHeight: 1,
|
|
||||||
fontWeight: 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,6 +8,27 @@ import Inbox from "./Inbox";
|
|||||||
import * as inboxApi from "@/lib/inbox-api";
|
import * as inboxApi from "@/lib/inbox-api";
|
||||||
import * as downloadModule from "@/lib/download";
|
import * as downloadModule from "@/lib/download";
|
||||||
|
|
||||||
|
// The Inbox's write-affordance BulkBars (resubmit / acknowledge /
|
||||||
|
// dismiss) are wrapped in RoleGate, which reads the auth context to
|
||||||
|
// decide whether the current user has write permission. We don't want
|
||||||
|
// the tests to spin up the full AuthProvider + /api/auth/me probe —
|
||||||
|
// instead we stub useAuth to return an admin user so RoleGate renders
|
||||||
|
// the BulkBars synchronously on first render.
|
||||||
|
vi.mock("@/auth/useAuth", () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
status: "authenticated" as const,
|
||||||
|
user: {
|
||||||
|
id: "test-admin",
|
||||||
|
username: "test-admin",
|
||||||
|
role: "admin" as const,
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
// SP21 Phase 4 Task 4.4: Inbox now uses `useNavigate` (for unmatched
|
// SP21 Phase 4 Task 4.4: Inbox now uses `useNavigate` (for unmatched
|
||||||
// claim drilldown to /claims?claim=ID), so the render harness needs
|
// claim drilldown to /claims?claim=ID), so the render harness needs
|
||||||
// a Router context. We use a fresh QueryClient per test so the
|
// a Router context. We use a fresh QueryClient per test so the
|
||||||
|
|||||||
+174
-131
@@ -29,6 +29,7 @@ import {
|
|||||||
acknowledgePayerRejected,
|
acknowledgePayerRejected,
|
||||||
} from "@/lib/inbox-api";
|
} from "@/lib/inbox-api";
|
||||||
import { downloadBlob } from "@/lib/download";
|
import { downloadBlob } from "@/lib/download";
|
||||||
|
import { RoleGate } from "@/auth/RoleGate";
|
||||||
|
|
||||||
type LaneKey =
|
type LaneKey =
|
||||||
| "rejected"
|
| "rejected"
|
||||||
@@ -224,9 +225,26 @@ export default function Inbox() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="min-h-screen"
|
className="min-h-screen relative"
|
||||||
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
||||||
>
|
>
|
||||||
|
{/* Ambient halo — a soft, fixed radial that creates a subtle
|
||||||
|
lighter zone behind the InboxHeader. The inbox has its own
|
||||||
|
dark ticker-tape background so it doesn't receive the body
|
||||||
|
wash; this overlay restores the "lit from above" feel that
|
||||||
|
the main pages get from the body::before softbox, in the
|
||||||
|
same warm-amber accent family as the rest of the surface.
|
||||||
|
Pointer-events disabled. */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none fixed inset-x-0 top-0 z-0 h-[55vh]"
|
||||||
|
style={{
|
||||||
|
background: `
|
||||||
|
radial-gradient(ellipse 60% 38% at 50% 0%, hsla(36, 60%, 30%, 0.10), transparent 65%),
|
||||||
|
radial-gradient(ellipse 35% 28% at 88% 8%, hsla(36, 70%, 50%, 0.07), transparent 60%)
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* SP21 Phase 4 Task 4.4: RemitDrawer mounts here so a row click
|
{/* SP21 Phase 4 Task 4.4: RemitDrawer mounts here so a row click
|
||||||
in the candidates / unmatched lanes drills into the parent
|
in the candidates / unmatched lanes drills into the parent
|
||||||
remit. The drawer portals into document.body (Radix Dialog),
|
remit. The drawer portals into document.body (Radix Dialog),
|
||||||
@@ -245,50 +263,11 @@ export default function Inbox() {
|
|||||||
/>
|
/>
|
||||||
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
|
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
|
||||||
|
|
||||||
{/* Fold — a thin amber rule + italic serif annotation that
|
{/* Lane grid transitions directly into the summary strip below.
|
||||||
transitions the editorial header into the lane grid below. */}
|
The previous "fold" divider with italic serif arrows and
|
||||||
<div
|
"Five lanes · one queue" caption was the page's last warm-
|
||||||
aria-hidden
|
paper print-shop artifact — removed so the ticker-tape
|
||||||
className="relative h-10 flex items-center"
|
aesthetic carries all the way through. */}
|
||||||
style={{ background: "var(--tt-bg)" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"linear-gradient(to right, transparent, var(--tt-amber) 12%, var(--tt-amber) 88%, transparent)",
|
|
||||||
opacity: 0.35,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="relative z-10 mx-auto px-3 flex items-center gap-2"
|
|
||||||
style={{ background: "var(--tt-bg)" }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="display italic"
|
|
||||||
style={{ color: "var(--tt-amber)", fontSize: 14 }}
|
|
||||||
>
|
|
||||||
↘
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="mono uppercase"
|
|
||||||
style={{
|
|
||||||
color: "var(--tt-ink-dim)",
|
|
||||||
fontSize: 10.5,
|
|
||||||
letterSpacing: "0.24em",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Five lanes · one queue
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="display italic"
|
|
||||||
style={{ color: "var(--tt-amber)", fontSize: 14 }}
|
|
||||||
>
|
|
||||||
↙
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main className="px-6 lg:px-10 pb-24 pt-4 flex gap-4 items-stretch flex-wrap">
|
<main className="px-6 lg:px-10 pb-24 pt-4 flex gap-4 items-stretch flex-wrap">
|
||||||
<Lane
|
<Lane
|
||||||
@@ -374,64 +353,101 @@ export default function Inbox() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* ----------------------------------------------------------------
|
{/* ----------------------------------------------------------------
|
||||||
PAPER-TONED SUMMARY STRIP
|
TICKER-TAPE QUEUE SUMMARY
|
||||||
A cream "ledger" panel below the lanes that shows the aggregate
|
A dark terminal panel below the lanes that aggregates the
|
||||||
eye-flow across the queue. Mirrors the "Magazine Spread" pattern
|
eye-flow across the queue. Sits on the same --tt-bg-elev
|
||||||
used on the Dashboard/Claims/etc pages so the Inbox feels part
|
surface as the Lane cards so the page reads as one continuous
|
||||||
of the same document set, not a stand-alone terminal.
|
instrument instead of mixing a paper-toned "ledger" panel
|
||||||
|
into the terminal aesthetic.
|
||||||
---------------------------------------------------------------- */}
|
---------------------------------------------------------------- */}
|
||||||
<section
|
<section
|
||||||
aria-label="Queue summary"
|
aria-label="Queue summary"
|
||||||
className="relative mx-6 lg:mx-10 mt-6 mb-4"
|
className="relative mx-6 lg:mx-10 mt-5 mb-6"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative rounded-xl overflow-hidden border"
|
className="relative rounded-md overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "hsl(var(--surface))",
|
// "Lit from above" gradient — same treatment as the lane
|
||||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
// cards and the main .surface panels, so the queue
|
||||||
|
// summary reads as a slightly lifted, subtly lighter
|
||||||
|
// surface against the ticker-tape backdrop.
|
||||||
|
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:
|
boxShadow:
|
||||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 12px 40px -16px hsl(0 0% 0% / 0.45)",
|
"inset 0 1px 0 0 hsl(0 0% 100% / 0.05), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Title row */}
|
{/* Header row — machine-tag eyebrow + editorial headline +
|
||||||
|
aggregate Need eyes count, separated from the lane metrics
|
||||||
|
by a hairline. */}
|
||||||
<div
|
<div
|
||||||
className="px-5 lg:px-7 py-4 border-b flex items-center justify-between gap-4 flex-wrap"
|
className="px-5 lg:px-6 py-3.5 flex items-center justify-between gap-4 flex-wrap"
|
||||||
style={{ borderColor: "hsl(30 14% 14% / 0.10)" }}
|
style={{ borderBottom: "1px solid hsl(220 8% 16%)" }}
|
||||||
>
|
>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div
|
<div className="flex items-center gap-2.5 mb-1.5">
|
||||||
className="mono text-[10.5px] uppercase tracking-[0.20em] font-semibold mb-1.5"
|
<span
|
||||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
aria-hidden
|
||||||
>
|
className="inline-block h-1.5 w-1.5 rounded-full animate-pulse-dot"
|
||||||
Queue ledger
|
style={{ background: "var(--tt-amber)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="mono uppercase"
|
||||||
|
style={{
|
||||||
|
color: "var(--tt-amber)",
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: "0.22em",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Eye-flow
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="mono uppercase"
|
||||||
|
style={{
|
||||||
|
color: "var(--tt-muted)",
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: "0.18em",
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
· five lanes, one queue
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h2
|
<h2
|
||||||
className="display leading-[0.95] tracking-[-0.03em]"
|
className="display italic"
|
||||||
style={{
|
style={{
|
||||||
color: "hsl(var(--surface-ink))",
|
color: "var(--tt-ink)",
|
||||||
fontSize: "clamp(22px, 2.2vw, 28px)",
|
fontSize: "clamp(20px, 1.9vw, 24px)",
|
||||||
|
lineHeight: 1.1,
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
The eye-flow, <span className="italic">at a glance.</span>
|
at a glance.
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="text-right" aria-label="Aggregate metrics">
|
||||||
className="text-right"
|
|
||||||
aria-label="Aggregate metrics"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
|
className="mono uppercase"
|
||||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
style={{
|
||||||
|
color: "var(--tt-ink-dim)",
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: "0.20em",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Need eyes
|
Need eyes
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="display tabular-nums"
|
className="display tabular-nums"
|
||||||
style={{
|
style={{
|
||||||
color: "hsl(var(--surface-ink))",
|
color: "var(--tt-amber)",
|
||||||
fontSize: 24,
|
fontSize: 28,
|
||||||
lineHeight: 1.1,
|
lineHeight: 1,
|
||||||
|
fontWeight: 400,
|
||||||
|
marginTop: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{needEyes}
|
{needEyes}
|
||||||
@@ -439,8 +455,13 @@ export default function Inbox() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lane metrics row — paper tiles that mirror each lane's accent */}
|
{/* Lane metrics row — each tile carries its own accent dot
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-5 divide-x divide-y lg:divide-y-0" style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}>
|
and count, so the operator can read the queue breakdown
|
||||||
|
at a glance without scanning back up to the Lane grid. */}
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
|
||||||
|
style={{ borderTop: "1px solid hsl(220 8% 16%)" }}
|
||||||
|
>
|
||||||
<SummaryTile
|
<SummaryTile
|
||||||
label="Rejected"
|
label="Rejected"
|
||||||
value={lanes.rejected.length}
|
value={lanes.rejected.length}
|
||||||
@@ -470,31 +491,43 @@ export default function Inbox() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Per-lane bulk bars. Each shows when its lane has a selection. */}
|
{/* Per-lane bulk bars. Each shows when its lane has a selection.
|
||||||
<BulkBar
|
The three write-affordance BulkBars (rejected → resubmit,
|
||||||
lane="rejected"
|
payer_rejected → acknowledge, candidates → dismiss) are gated
|
||||||
count={selected.rejected.length}
|
by RoleGate so a viewer-role account can still SEE the rows
|
||||||
onResubmit={onResubmit}
|
and select them but cannot trigger a state change. The export
|
||||||
onAcknowledge={() => {}}
|
buttons live inside the same BulkBar component but are
|
||||||
onDismiss={() => {}}
|
read-only, so we let them remain visible to viewers. */}
|
||||||
onExport={() => onExport("rejected")}
|
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||||
/>
|
<BulkBar
|
||||||
<BulkBar
|
lane="rejected"
|
||||||
lane="payer_rejected"
|
count={selected.rejected.length}
|
||||||
count={selected.payer_rejected.length}
|
onResubmit={onResubmit}
|
||||||
onResubmit={() => {}}
|
onAcknowledge={() => {}}
|
||||||
onAcknowledge={onAcknowledge}
|
onDismiss={() => {}}
|
||||||
onDismiss={() => {}}
|
onExport={() => onExport("rejected")}
|
||||||
onExport={() => onExport("payer_rejected")}
|
/>
|
||||||
/>
|
</RoleGate>
|
||||||
<BulkBar
|
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||||
lane="candidates"
|
<BulkBar
|
||||||
count={selected.candidates.length}
|
lane="payer_rejected"
|
||||||
onResubmit={() => {}}
|
count={selected.payer_rejected.length}
|
||||||
onAcknowledge={() => {}}
|
onResubmit={() => {}}
|
||||||
onDismiss={onDismiss}
|
onAcknowledge={onAcknowledge}
|
||||||
onExport={() => onExport("candidates")}
|
onDismiss={() => {}}
|
||||||
/>
|
onExport={() => onExport("payer_rejected")}
|
||||||
|
/>
|
||||||
|
</RoleGate>
|
||||||
|
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||||
|
<BulkBar
|
||||||
|
lane="candidates"
|
||||||
|
count={selected.candidates.length}
|
||||||
|
onResubmit={() => {}}
|
||||||
|
onAcknowledge={() => {}}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
onExport={() => onExport("candidates")}
|
||||||
|
/>
|
||||||
|
</RoleGate>
|
||||||
<BulkBar
|
<BulkBar
|
||||||
lane="unmatched"
|
lane="unmatched"
|
||||||
count={selected.unmatched.length}
|
count={selected.unmatched.length}
|
||||||
@@ -629,33 +662,31 @@ export default function Inbox() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SummaryTile — paper-toned metric for the queue ledger strip. Mirrors
|
// SummaryTile — ticker-tape metric for the eye-flow strip. Each tile
|
||||||
// the lane accent (oxblood / amber / ink-blue / muted) so the eye-flow
|
// carries the lane's accent dot + label + count in the same color
|
||||||
// reads at a glance.
|
// family the Lane component uses (oxblood / amber / ink-blue / muted),
|
||||||
|
// so the operator can read the queue breakdown at a glance without
|
||||||
|
// scanning back up to the lane grid.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const SUMMARY_ACCENTS: Record<
|
const SUMMARY_ACCENTS: Record<
|
||||||
"oxblood" | "amber" | "ink" | "muted",
|
"oxblood" | "amber" | "ink" | "muted",
|
||||||
{ dot: string; tint: string; text: string }
|
{ dot: string; value: string }
|
||||||
> = {
|
> = {
|
||||||
oxblood: {
|
oxblood: {
|
||||||
dot: "hsl(358 70% 42%)",
|
dot: "var(--tt-oxblood)",
|
||||||
tint: "hsl(358 70% 92%)",
|
value: "var(--tt-ink)",
|
||||||
text: "hsl(358 70% 30%)",
|
|
||||||
},
|
},
|
||||||
amber: {
|
amber: {
|
||||||
dot: "hsl(36 92% 50%)",
|
dot: "var(--tt-amber)",
|
||||||
tint: "hsl(36 82% 88%)",
|
value: "var(--tt-amber)",
|
||||||
text: "hsl(36 92% 30%)",
|
|
||||||
},
|
},
|
||||||
ink: {
|
ink: {
|
||||||
dot: "hsl(212 80% 45%)",
|
dot: "var(--tt-ink-blue)",
|
||||||
tint: "hsl(212 85% 92%)",
|
value: "var(--tt-ink)",
|
||||||
text: "hsl(212 80% 30%)",
|
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
dot: "hsl(30 8% 50%)",
|
dot: "var(--tt-muted)",
|
||||||
tint: "hsl(36 22% 90%)",
|
value: "var(--tt-ink-dim)",
|
||||||
text: "hsl(30 8% 30%)",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -672,41 +703,53 @@ function SummaryTile({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative px-4 py-4 first:pl-5 lg:first:pl-7 last:pr-5 lg:last:pr-7"
|
"relative px-4 py-4 first:pl-5 lg:first:pl-6 last:pr-5 lg:last:pr-6"
|
||||||
)}
|
)}
|
||||||
|
style={{ borderRight: "1px solid hsl(220 8% 14%)" }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="block h-1.5 w-1.5 rounded-full"
|
className="block h-1.5 w-1.5 rounded-full"
|
||||||
style={{ backgroundColor: a.dot }}
|
style={{
|
||||||
|
backgroundColor: a.dot,
|
||||||
|
boxShadow: `0 0 6px ${a.dot}`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="mono text-[10px] uppercase tracking-[0.20em] font-semibold"
|
className="mono uppercase"
|
||||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
style={{
|
||||||
|
color: "var(--tt-ink-dim)",
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: "0.18em",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="display tabular-nums tracking-[-0.03em]"
|
className="display tabular-nums"
|
||||||
style={{
|
style={{
|
||||||
color: "hsl(var(--surface-ink))",
|
color: a.value,
|
||||||
fontSize: "clamp(24px, 2vw, 30px)",
|
fontSize: "clamp(22px, 1.9vw, 28px)",
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="mono text-[10px] uppercase tracking-[0.16em] mt-1.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm"
|
className="mono uppercase mt-1.5"
|
||||||
style={{
|
style={{
|
||||||
color: a.text,
|
color: "var(--tt-muted)",
|
||||||
backgroundColor: a.tint,
|
fontSize: 9.5,
|
||||||
|
letterSpacing: "0.16em",
|
||||||
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{value === 0 ? "Clear" : value === 1 ? "row" : "rows"}
|
{value === 0 ? "clear" : value === 1 ? "row" : "rows"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||||
|
import { Login } from "./Login";
|
||||||
|
|
||||||
|
vi.mock("@/auth/useAuth", () => ({ useAuth: vi.fn() }));
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const login = vi.fn();
|
||||||
|
(useAuth as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
user: null,
|
||||||
|
status: "unauthenticated",
|
||||||
|
login,
|
||||||
|
logout: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
});
|
||||||
|
return { login };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Login page", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits username + password", async () => {
|
||||||
|
const { login } = setup();
|
||||||
|
login.mockResolvedValue(undefined);
|
||||||
|
const { getByLabelText, getByRole } = render(
|
||||||
|
<MemoryRouter initialEntries={["/login"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/" element={<div>HOME</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
fireEvent.change(getByLabelText(/username/i), {
|
||||||
|
target: { value: "alice" },
|
||||||
|
});
|
||||||
|
fireEvent.change(getByLabelText(/password/i), {
|
||||||
|
target: { value: "hunter2hunter2" },
|
||||||
|
});
|
||||||
|
fireEvent.click(getByRole("button", { name: /sign in/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(login).toHaveBeenCalledWith("alice", "hunter2hunter2")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error on failed login", async () => {
|
||||||
|
const { login } = setup();
|
||||||
|
login.mockRejectedValue(
|
||||||
|
Object.assign(new Error("401"), { code: "invalid_credentials" })
|
||||||
|
);
|
||||||
|
const { getByLabelText, getByRole, getByText } = render(
|
||||||
|
<MemoryRouter initialEntries={["/login"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
fireEvent.change(getByLabelText(/username/i), {
|
||||||
|
target: { value: "alice" },
|
||||||
|
});
|
||||||
|
fireEvent.change(getByLabelText(/password/i), {
|
||||||
|
target: { value: "wrong" },
|
||||||
|
});
|
||||||
|
fireEvent.click(getByRole("button", { name: /sign in/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getByText(/username or password is incorrect/i)).toBeTruthy()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to `next` query param on success", async () => {
|
||||||
|
const { login } = setup();
|
||||||
|
login.mockResolvedValue(undefined);
|
||||||
|
const { getByLabelText, getByRole, getByText } = render(
|
||||||
|
<MemoryRouter initialEntries={["/login?next=/claims"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/claims" element={<div>CLAIMS</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
fireEvent.change(getByLabelText(/username/i), {
|
||||||
|
target: { value: "alice" },
|
||||||
|
});
|
||||||
|
fireEvent.change(getByLabelText(/password/i), {
|
||||||
|
target: { value: "hunter2hunter2" },
|
||||||
|
});
|
||||||
|
fireEvent.click(getByRole("button", { name: /sign in/i }));
|
||||||
|
await waitFor(() => expect(getByText("CLAIMS")).toBeTruthy());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useNavigate, useSearchParams, Navigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-in screen. Mounted by the `/login` route in `App.tsx`, which
|
||||||
|
* sits outside `<RequireAuth>` so unauthenticated operators can reach
|
||||||
|
* it. The page is also reachable via the auto-redirect on a 401 from
|
||||||
|
* `authedFetch` — that path appends `?next=<current>` so the operator
|
||||||
|
* lands back on the page they were trying to view.
|
||||||
|
*
|
||||||
|
* Error mapping is driven by the backend's `error` code on the
|
||||||
|
* `ApiError` thrown from `authApi.login`. The codes are defined in
|
||||||
|
* `backend/src/cyclone/auth/routes.py::login`.
|
||||||
|
*/
|
||||||
|
export function Login() {
|
||||||
|
const { user, login } = useAuth();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const next = params.get("next") ?? "/";
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Already authenticated — bounce to the original destination (or
|
||||||
|
// dashboard) so we don't show a sign-in form to a logged-in user.
|
||||||
|
if (user) return <Navigate to={next} replace />;
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
navigate(next, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as { code?: string })?.code ?? "error";
|
||||||
|
if (code === "invalid_credentials") {
|
||||||
|
setError("Username or password is incorrect.");
|
||||||
|
} else if (code === "account_disabled") {
|
||||||
|
setError("Account is disabled. Contact your administrator.");
|
||||||
|
} else if (code === "rate_limited") {
|
||||||
|
setError("Too many attempts. Try again shortly.");
|
||||||
|
} else {
|
||||||
|
setError("Sign in failed. Try again.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background text-foreground p-6">
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="w-full max-w-sm p-8 rounded-lg border border-border bg-card shadow-lg"
|
||||||
|
>
|
||||||
|
<h1 className="text-2xl font-semibold mb-1 tracking-tight">Cyclone</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Sign in to continue.
|
||||||
|
</p>
|
||||||
|
<label className="block mb-4">
|
||||||
|
<span className="text-sm font-medium">Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded bg-background border border-border text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block mb-5">
|
||||||
|
<span className="text-sm font-medium">Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="mt-1 w-full py-2 px-3 rounded bg-background border border-border text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error ? (
|
||||||
|
<div
|
||||||
|
className="mb-4 text-sm text-red-400 bg-red-400/10 border border-red-400/30 rounded px-3 py-2"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full py-2 rounded bg-primary text-primary-foreground font-medium disabled:opacity-50 hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{submitting ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,25 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { ReconciliationPage } from "./Reconciliation";
|
import { ReconciliationPage } from "./Reconciliation";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
// "Match selected" is wrapped in RoleGate, which reads the auth
|
||||||
|
// context. Stub useAuth to return an admin user so the gate renders
|
||||||
|
// the button synchronously on first render — no AuthProvider + /me
|
||||||
|
// probe needed in the test harness.
|
||||||
|
vi.mock("@/auth/useAuth", () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
status: "authenticated" as const,
|
||||||
|
user: {
|
||||||
|
id: "test-admin",
|
||||||
|
username: "test-admin",
|
||||||
|
role: "admin" as const,
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
// Module-level mock: vitest hoists `vi.mock` calls above imports. Only the
|
// Module-level mock: vitest hoists `vi.mock` calls above imports. Only the
|
||||||
// methods this page touches need to be stubbed; ApiError is re-exported so
|
// methods this page touches need to be stubbed; ApiError is re-exported so
|
||||||
// the page can branch on .status in its catch handler.
|
// the page can branch on .status in its catch handler.
|
||||||
|
|||||||
+333
-779
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user