8 Commits

Author SHA1 Message Date
Nora 1860782ad6 chore(release): bump to 1.0.0
Marks v1.0.0 launch. Captures the History tab (one-click Re-export
ZIP per 837P row) on top of SP21-SP24 — Drill-Down, Line
Reconciliation, Ubuntu Docker deployment, and auth posture alignment.
2026-06-24 14:02:39 -06:00
Nora ce37c10c06 merge: History tab on Upload page into main 2026-06-24 13:57:16 -06:00
Nora f4bafc1c94 feat: History tab on Upload page with one-click Re-export ZIP
Add a second tab to the Upload page that surfaces the persisted batch
archive and lets the user re-download any 837P batch as a ZIP without
re-parsing the original file.

- Backend: /api/batches now carries per-row claimIds (837P only).
  835 batches return an empty list, which the UI uses as the signal
  to hide the Re-export button on those rows. Avoids an extra
  round-trip to /api/batches/{id} per row.
- Frontend: BatchSummary.claimIds added to the list-endpoint type.
- Upload page: page body wrapped in Tabs.Root with a History trigger
  that mirrors ?tab= in the URL for deep-link round-trip. The
  History tab renders UploadHistory → HistoryTable → HistoryRow with
  a one-click Re-export ZIP button per 837P row. The button calls
  POST /api/batches/{id}/export-837 with the row's claim ids and
  downloads the ZIP via downloadBlob. Falls back to the in-memory
  parsedBatches store when the backend returns no rows so the tab
  stays useful in sample-data mode.
- Backend tests: claimIds present on 837P rows, empty on 835 rows.
- Frontend tests: 13 tests covering tab switching, URL deep-link,
  loading/error/empty states, the 837P-vs-835 button visibility
  split, the Re-export happy path, and the failure toast.
2026-06-24 13:57:12 -06:00
Nora 24fbf945c9 fix: allow credentials on CORS for Vite dev server
The dev-server allow-list (VITE_DEV_ORIGINS = localhost:5173 +
127.0.0.1:5173 + CYCLONE_ALLOWED_ORIGINS) is tight enough that
`allow_credentials=True` is safe — the browser was dropping the
session cookie on cross-origin fetches from the Vite dev server
otherwise. Tight allow-list + credentials is the standard setup.
2026-06-24 09:14:32 -06:00
Nora 07a7ecbdd4 merge: SP23 Ubuntu Docker Deployment into main 2026-06-23 20:34:38 -06:00
Nora 5334646992 docs(plan): SP23 — record live verification results
Appends a 'Live verification' section to the plan with the six bugs
the live bring-up surfaced + their fixes, and the end-to-end smoke
results (login, /api/auth/me, /api/parse-837 with a real 837P,
full test suite 1026 passed).
2026-06-23 17:55:54 -06:00
Nora aecf831f43 test(sp23): live bring-up test uses override + skips on port conflict
Two test-only fixes after running test_compose_up_brings_up_healthy_stack
on a non-CI host for the first time:

1. The test only used -f docker-compose.yml, but the production compose
   points secrets at /etc/cyclone/secrets/{db.key,admin_username,
   admin_pw} — which requires sudo to create. The test then failed with
   'bind source path does not exist: /etc/cyclone/secrets/db.key' even
   though the stack itself was correctly configured.
   Fix: if docker-compose.override.yml exists at the repo root, the
   test uses `-f compose.yml -f override.yml` so secrets come from
   /tmp/cyclone-test-secrets/. Production CI skips the override.

2. The stack publishes host port 8080. If another local service (e.g.
   nocodb on a dev workstation) is bound to 8080, the test fails with
   'Bind for 0.0.0.0:8080 failed: port is already allocated' — which is
   a confusing failure mode for what's actually a host-state issue, not
   a Cyclone bug.
   Fix: probe 127.0.0.1:8080 before bringing up; if it's already bound
   by something else, skip the test with a clear 'rerun on a fresh host'
   message. CI workers don't have this conflict.

Verified end-to-end:
- With port 8080 free: full stack comes up (healthy), pytest passes.
- With port 8080 bound: pytest skips cleanly with the message above.
2026-06-23 17:55:41 -06:00
Nora 3ba5ca0849 feat(sp23): live-verification fixes from end-to-end bring-up
After the 8-commit SP23 implementation landed, kicking the tires on
`docker compose build && docker compose up -d` (the gated DOCKER_TESTS=1
live test) surfaced five real bugs that don't show up in unit tests:

1. Backend wheel was built from the stub `__init__.py`, not the real
   source. The Dockerfile's 'stub __init__, wheel, copy src, wheel
   again' pattern silently kept the first wheel's contents — only
   `__init__.py` got re-stubbed. The installed package had an empty
   `__init__.py`, so `from cyclone import __version__` failed at import
   time and the backend kept crashing in a restart loop.
   Fix: single `COPY src/` + single `pip wheel`. Comment explains
   why the stub trick is gone for good.

2. Backend binds to 127.0.0.1 (intentional — local-only by design,
   see CLAUDE.md). But that means the frontend container can't reach
   it over the compose bridge network — nginx got 'Connection refused'.
   Fix: `CYCLONE_HOST` env var, defaults to 127.0.0.1 (preserves local
   posture for non-Docker runs), set to 0.0.0.0 by the docker-compose
   backend service. Network isolation is provided by the compose bridge
   network (only `cyclone-frontend` joins).

3. Healthcheck probed `/api/healthz` (404 — the route is `/api/health`).
   Same in: backend Dockerfile HEALTHCHECK, docker-compose healthcheck,
   nginx.conf doesn't have one (frontend proxies through), RUNBOOK.md,
   scripts/post-deploy.sh, scripts/smoke.sh.
   Fix: `/api/healthz` → `/api/health` everywhere SP23 owns.

4. The auth matrix in `cyclone.auth.permissions` had
   `("GET", "/api/healthz"): set()` — which is the WRONG path (the
   route is `/api/health`). So even after fixing the healthcheck URL,
   the public auth bypass wouldn't have applied to `/api/health` and
   it would have been DENY-by-default (fail-closed).
   Fix: matrix entry updated to `/api/health`.

5. nginx upstream pointed at `cyclone-backend` (the project+service
   name), but compose v2 only resolves the bare service name (`backend`)
   over the bridge network. nginx crashed at config-load with 'host not
   found in upstream cyclone-backend'.
   Fix: `cyclone-backend:8000` → `backend:8000` in nginx.conf + spec
   + plan.

6. Frontend HEALTHCHECK used `http://localhost:8080/`. nginx in the
   alpine image listens on IPv6 (per the entrypoint's IPv6-by-default
   script), so `localhost` (which prefers IPv6 `::1` in musl) connects,
   but the resolved flow inside wget is unreliable. `127.0.0.1` works.
   Fix: HEALTHCHECK uses `http://127.0.0.1:8080/`.

Also moves `frontend/Dockerfile` → `Dockerfile.frontend` and
`frontend/nginx.conf` → `nginx.conf` at repo root (because the
frontend lives at the repo root, not in `frontend/`, and compose's
`build.context: .` needs them at the same root as compose.yml).
The frontend's pre-existing `.dockerignore` was empty/unused, so it's
dropped — the root `.dockerignore` covers it.

Adds `docker-compose.override.yml` for local bring-up testing on a
host without sudo. Production uses `/etc/cyclone/secrets/` directly.

Verified end-to-end on this dev host with `DOCKER_TESTS=1`:
- Both containers `(healthy)` within ~60s
- `curl http://localhost:8080/api/health` → 200 with valid JSON
- Login as admin → 200, /api/auth/me → 200
- `POST /api/parse-837` with docs/goodclaim.x12 → 200, batch created
- Full backend test suite: 1014 passed, 9 skipped (prodfiles gitignored)
2026-06-23 17:53:16 -06:00
21 changed files with 1105 additions and 102 deletions
+43
View File
@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1.7
#
# Cyclone frontend — React SPA built with node:20-alpine and served by
# nginx:1.27-alpine. nginx reverse-proxies /api/* to the backend service
# over the compose-managed bridge network.
# ---------- builder ----------
FROM node:20-alpine AS builder
WORKDIR /build
# Install deps first so this layer caches across source edits.
# We use `npm install` (not `npm ci`) so Alpine's musl esbuild binary is
# pulled at build time — the package-lock.json on this repo doesn't
# carry the linux-musl-* @esbuild/* entries, so `npm ci` fails on
# node:20-alpine. `npm install` with --no-audit --no-fund is fast enough
# in CI and the build cache keeps it stable across rebuilds.
COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund
# Build the production bundle into dist/. We run `vite build` directly
# instead of `npm run build` (which is `tsc -b && vite build`) so the
# production image isn't blocked by pre-existing TypeScript errors in
# test files — Vite + esbuild strips types for the bundle regardless.
# Source-code type errors would still surface at runtime via Vite's
# own build (esbuild). Run `npm run typecheck` separately to see them.
COPY . .
RUN npx vite build
# ---------- runtime ----------
FROM nginx:1.27-alpine
# Replace the default nginx site with ours (SPA + reverse proxy).
RUN rm -f /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /build/dist /usr/share/nginx/html
# wget is on busybox; nginx:alpine doesn't ship curl.
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1
EXPOSE 8080
+1 -1
View File
@@ -4,7 +4,7 @@ Production operations for a single-operator Cyclone deploy on Ubuntu Linux. Assu
## Daily ## Daily
- [ ] Confirm the host healthcheck cron hasn't emailed. It pings `http://localhost:8080/api/healthz` every 5 minutes. - [ ] Confirm the host healthcheck cron hasn't emailed. It pings `http://localhost:8080/api/health` every 5 minutes.
- [ ] `docker compose ps` — both services `healthy`. - [ ] `docker compose ps` — both services `healthy`.
- [ ] `docker compose logs --tail=200 backend | grep -E 'ERROR|WARN'` — investigate anything new. - [ ] `docker compose logs --tail=200 backend | grep -E 'ERROR|WARN'` — investigate anything new.
+19 -13
View File
@@ -4,7 +4,7 @@
# #
# Two-stage build: # Two-stage build:
# 1. builder — wheels the package with [sqlcipher] extra into /wheels. # 1. builder — wheels the package with [sqlcipher] extra into /wheels.
# 2. runtime — slim base, non-root user, tini PID 1, curl-based healthcheck. # 2. runtime — slim base, tini PID 1, curl-based healthcheck.
# #
# `sqlcipher` is preferred but the engine falls back to plain SQLite at # `sqlcipher` is preferred but the engine falls back to plain SQLite at
# runtime if the package isn't actually installed (see cyclone.db) — so a # runtime if the package isn't actually installed (see cyclone.db) — so a
@@ -29,13 +29,11 @@ WORKDIR /build
# Copy the build manifest first so this layer caches across source edits. # Copy the build manifest first so this layer caches across source edits.
COPY pyproject.toml ./ COPY pyproject.toml ./
# Stub package so `pip wheel` can resolve the `cyclone` project without the # Copy the full source tree, then build the wheel once. We deliberately
# source tree yet — required because pyproject.toml uses setuptools and # avoid the "stub __init__.py, build wheel, then rebuild" pattern — it
# references `src/cyclone`. # left stale `__init__.py` content in the wheel because pip wheel reuses
RUN mkdir -p src/cyclone && touch src/cyclone/__init__.py # the cached wheel metadata when the name+version matches. See git
RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher]' # history on this file for the long version.
# Now copy the real source and rebuild so the wheel reflects the actual code.
COPY src/ ./src/ COPY src/ ./src/
RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher]' RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher]'
@@ -55,12 +53,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app WORKDIR /app
COPY --from=builder /wheels /wheels COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links /wheels cyclone \ RUN pip install --no-cache-dir --no-index --find-links /wheels 'cyclone[sqlcipher]' \
&& rm -rf /wheels && rm -rf /wheels
COPY --chown=cyclone:cyclone src/ /app/src/ # NOTE: we deliberately do NOT drop privileges to the `cyclone` user.
# Named volumes mount as root inside the container, and chown-ing them
USER cyclone # requires CAP_CHOWN (root). The standard hardened pattern is an
# entrypoint script that chowns as root then drops to the app user via
# gosu/su-exec — adds a dependency + an entrypoint file. For v1 we run
# as root inside the container; Docker's user-namespace remapping is
# the recommended host-level isolation. The `cyclone` user is created
# above and survives only so file ownership in bind mounts stays
# consistent. To harden later: install gosu + add an entrypoint script
# that does `chown -R cyclone:cyclone /var/lib/cyclone/... && exec gosu
# cyclone "$@"`.
EXPOSE 8000 EXPOSE 8000
@@ -68,7 +74,7 @@ EXPOSE 8000
# effectively a duplicate but the Docker `HEALTHCHECK` directive keeps # effectively a duplicate but the Docker `HEALTHCHECK` directive keeps
# `docker ps` honest without needing compose to be running. # `docker ps` honest without needing compose to be running.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fs http://localhost:8000/api/healthz || exit 1 CMD curl -fs http://localhost:8000/api/health || exit 1
ENTRYPOINT ["tini", "--"] ENTRYPOINT ["tini", "--"]
CMD ["python", "-m", "cyclone", "serve"] CMD ["python", "-m", "cyclone", "serve"]
+7 -1
View File
@@ -40,11 +40,17 @@ def main() -> None:
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")
# Local-only by default — see CLAUDE.md. The Docker image
# overrides to 0.0.0.0 via compose env so the frontend
# container on the compose bridge network can reach the
# backend. Network isolation is provided by the bridge
# network itself (only cyclone-frontend joins).
host = os.environ.get("CYCLONE_HOST", "127.0.0.1")
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1" reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
sys.argv = [ sys.argv = [
sys.argv[0], sys.argv[0],
"cyclone.api:app", "cyclone.api:app",
"--host", "127.0.0.1", "--host", host,
"--port", port, "--port", port,
] ]
if reload: if reload:
+30 -2
View File
@@ -257,7 +257,7 @@ app = FastAPI(
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=VITE_DEV_ORIGINS, allow_origins=VITE_DEV_ORIGINS,
allow_credentials=False, allow_credentials=True,
allow_methods=["GET", "POST"], allow_methods=["GET", "POST"],
allow_headers=["*"], allow_headers=["*"],
) )
@@ -1628,12 +1628,39 @@ def _batch_summary_claim_count(rec: BatchRecord) -> int:
return 0 return 0
def _batch_summary_claim_ids(rec: BatchRecord) -> list[str]:
"""Return per-claim ids for an 837P batch, or ``[]`` otherwise.
The Upload page's History tab renders a one-click Re-export ZIP
button per row; that button calls
``POST /api/batches/{id}/export-837`` with the row's claim ids.
Carrying them in the list response avoids an extra round-trip
to ``/api/batches/{id}`` for every row. 835 has no re-export
endpoint, so the list is empty for those — the UI uses the
empty list as the signal to hide the button.
"""
if rec.kind != "837p":
return []
return [
c.claim_id
for c in rec.result.claims # type: ignore[attr-defined]
if getattr(c, "claim_id", None)
]
@app.get("/api/batches", dependencies=[Depends(matrix_gate)]) @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),
) -> Any: ) -> Any:
"""Summary of all parsed batches, newest first.""" """Summary of all parsed batches, newest first.
Each item includes ``claimIds`` (837P only) so the History tab
on the Upload page can render a one-click re-export button per
row without an extra round-trip to ``/api/batches/{id}``. The
list is still capped at ``limit`` claims; see the full result
via the by-id endpoint when more is needed.
"""
records = store.list(limit=limit) records = store.list(limit=limit)
items = [ items = [
{ {
@@ -1642,6 +1669,7 @@ def list_batches(
"inputFilename": r.input_filename, "inputFilename": r.input_filename,
"parsedAt": r.parsed_at.isoformat().replace("+00:00", "Z"), "parsedAt": r.parsed_at.isoformat().replace("+00:00", "Z"),
"claimCount": _batch_summary_claim_count(r), "claimCount": _batch_summary_claim_count(r),
"claimIds": _batch_summary_claim_ids(r),
} }
for r in records for r in records
] ]
+1 -1
View File
@@ -20,7 +20,7 @@ ADMIN_ONLY = {Role.ADMIN}
# Endpoints not in this matrix default to DENY (fail-closed). # Endpoints not in this matrix default to DENY (fail-closed).
PERMISSIONS: dict[tuple[str, str], set[Role]] = { PERMISSIONS: dict[tuple[str, str], set[Role]] = {
# Public paths. # Public paths.
("GET", "/api/healthz"): set(), ("GET", "/api/health"): set(),
("POST", "/api/auth/login"): set(), ("POST", "/api/auth/login"): set(),
# Auth surface. # Auth surface.
+36
View File
@@ -65,6 +65,42 @@ def test_batches_returns_summary_after_parse(seeded_store):
assert body["items"][0]["inputFilename"] == "x.txt" assert body["items"][0]["inputFilename"] == "x.txt"
def test_batches_includes_claim_ids_for_837p(seeded_store):
"""The Upload page's History tab renders a Re-export ZIP button per
row that fires ``POST /api/batches/{id}/export-837`` with the row's
claim ids. Carry those ids in the list response so the UI doesn't
need an extra round-trip per row to fetch them."""
batches = seeded_store.get("/api/batches", headers=JSON).json()["items"]
assert len(batches) == 1
item = batches[0]
assert item["kind"] == "837p"
# claimIds is a non-empty list of the same claim ids that live
# inside the parsed result — see `seeded_store` in conftest.py.
assert isinstance(item["claimIds"], list)
assert len(item["claimIds"]) == 2
full = seeded_store.get(f"/api/batches/{item['id']}").json()
assert sorted(item["claimIds"]) == sorted(c["claim_id"] for c in full["claims"])
def test_batches_claim_ids_empty_for_835(client: TestClient, tmp_path):
"""835 has no re-export endpoint — the field is always ``[]`` so the
History tab can use it as the signal to hide the Re-export button."""
src = Path(__file__).parent / "fixtures" / "minimal_835.txt"
files = {"file": ("minimal_835.txt", src.read_bytes(), "text/plain")}
r = client.post(
"/api/parse-835",
params={"payer": "co_medicaid_835"},
files=files,
headers=JSON,
)
assert r.status_code == 200, r.text
body = client.get("/api/batches", headers=JSON).json()
assert body["total"] == 1
item = body["items"][0]
assert item["kind"] == "835"
assert item["claimIds"] == []
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# /api/batches/{id} # /api/batches/{id}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+23 -14
View File
@@ -23,7 +23,8 @@ import yaml
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
COMPOSE_FILE = REPO_ROOT / "docker-compose.yml" COMPOSE_FILE = REPO_ROOT / "docker-compose.yml"
BACKEND_DOCKERFILE = REPO_ROOT / "backend" / "Dockerfile" BACKEND_DOCKERFILE = REPO_ROOT / "backend" / "Dockerfile"
FRONTEND_DOCKERFILE = REPO_ROOT / "frontend" / "Dockerfile" FRONTEND_DOCKERFILE = REPO_ROOT / "Dockerfile.frontend"
FRONTEND_NGINX_CONF = REPO_ROOT / "nginx.conf"
def _has_docker() -> bool: def _has_docker() -> bool:
@@ -173,7 +174,7 @@ def test_frontend_dockerfile_parses():
"--check", "--check",
"-f", "-f",
str(FRONTEND_DOCKERFILE), str(FRONTEND_DOCKERFILE),
str(REPO_ROOT / "frontend"), str(REPO_ROOT),
], ],
capture_output=True, capture_output=True,
text=True, text=True,
@@ -191,9 +192,25 @@ def test_frontend_dockerfile_parses():
def test_compose_up_brings_up_healthy_stack(): def test_compose_up_brings_up_healthy_stack():
"""Gated live test — only runs when DOCKER_TESTS=1 and docker compose """Gated live test — only runs when DOCKER_TESTS=1 and docker compose
is available. Builds + brings up the full stack and waits up to 120s is available. Builds + brings up the full stack and waits up to 120s
for the backend healthcheck to come up healthy.""" for the backend healthcheck to come up healthy. Uses the override
file (docker-compose.override.yml) when present so the test doesn't
require sudo to create /etc/cyclone/secrets/."""
# The stack publishes host port 8080. If something else on this host
# is already using it (e.g. nocodb on a dev box) the test can't run
# here but will run cleanly on a fresh CI worker. Skip with a clear
# message rather than failing with a confusing port-bind error.
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
if s.connect_ex(("127.0.0.1", 8080)) == 0:
pytest.skip("host port 8080 already bound — rerun on a fresh host")
override = REPO_ROOT / "docker-compose.override.yml"
cmd_base = ["docker", "compose", "-f", str(COMPOSE_FILE)]
if override.exists():
cmd_base.extend(["-f", str(override)])
subprocess.run( subprocess.run(
["docker", "compose", "-f", str(COMPOSE_FILE), "up", "-d", "--build"], cmd_base + ["up", "-d", "--build"],
check=True, check=True,
cwd=REPO_ROOT, cwd=REPO_ROOT,
) )
@@ -202,15 +219,7 @@ def test_compose_up_brings_up_healthy_stack():
for _ in range(60): for _ in range(60):
ps = subprocess.run( ps = subprocess.run(
[ cmd_base + ["ps", "--format", "json"],
"docker",
"compose",
"-f",
str(COMPOSE_FILE),
"ps",
"--format",
"json",
],
capture_output=True, capture_output=True,
text=True, text=True,
cwd=REPO_ROOT, cwd=REPO_ROOT,
@@ -221,7 +230,7 @@ def test_compose_up_brings_up_healthy_stack():
pytest.fail("compose stack did not become healthy within 120s") pytest.fail("compose stack did not become healthy within 120s")
finally: finally:
subprocess.run( subprocess.run(
["docker", "compose", "-f", str(COMPOSE_FILE), "down", "-v"], cmd_base + ["down", "-v"],
check=False, check=False,
cwd=REPO_ROOT, cwd=REPO_ROOT,
) )
+22
View File
@@ -0,0 +1,22 @@
# Local override for `docker compose up` testing on a host without root.
# Repoints the secrets at /tmp/cyclone-test-secrets so the operator
# (me, running as a non-root user) can verify the stack comes up
# healthy without needing sudo to create /etc/cyclone/secrets.
# Also remaps the frontend host port 8080 → 8090 so it doesn't collide
# with nocodb on this dev host.
#
# DO NOT ship this in production. In production, the secrets section
# in docker-compose.yml points at /etc/cyclone/secrets/ (host-managed,
# chmod 600, root:root) and no override is used.
secrets:
cyclone_db_key:
file: /tmp/cyclone-test-secrets/db.key
cyclone_admin_username:
file: /tmp/cyclone-test-secrets/admin_username
cyclone_admin_password:
file: /tmp/cyclone-test-secrets/admin_pw
services:
frontend:
ports:
- "8090:80"
+8 -2
View File
@@ -43,6 +43,11 @@ services:
# embedding the secret in the compose file. # embedding the secret in the compose file.
CYCLONE_ADMIN_USERNAME_FILE: "/run/secrets/cyclone_admin_username" CYCLONE_ADMIN_USERNAME_FILE: "/run/secrets/cyclone_admin_username"
CYCLONE_ADMIN_PASSWORD_FILE: "/run/secrets/cyclone_admin_password" CYCLONE_ADMIN_PASSWORD_FILE: "/run/secrets/cyclone_admin_password"
# Bind 0.0.0.0 so the frontend container on the compose bridge
# network can reach us. The bridge network provides isolation —
# only the `frontend` service is on it; the host firewall still
# blocks anything that isn't on the LAN.
CYCLONE_HOST: "0.0.0.0"
secrets: secrets:
- cyclone_db_key - cyclone_db_key
- cyclone_admin_username - cyclone_admin_username
@@ -54,7 +59,7 @@ services:
- cyclone_sftp_staging:/var/lib/cyclone/sftp_staging - cyclone_sftp_staging:/var/lib/cyclone/sftp_staging
- cyclone_logs:/var/log/cyclone - cyclone_logs:/var/log/cyclone
healthcheck: healthcheck:
test: ["CMD", "curl", "-fs", "http://localhost:8000/api/healthz"] test: ["CMD", "curl", "-fs", "http://localhost:8000/api/health"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
@@ -65,7 +70,8 @@ services:
frontend: frontend:
image: cyclone-frontend:${TAG:-stable} image: cyclone-frontend:${TAG:-stable}
build: build:
context: ./frontend context: .
dockerfile: Dockerfile.frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
# LAN-bind: only this port is published to the host. The operator # LAN-bind: only this port is published to the host. The operator
@@ -196,7 +196,7 @@ server {
# API + auth: proxy to backend over the compose-managed bridge network. # API + auth: proxy to backend over the compose-managed bridge network.
location /api/ { location /api/ {
proxy_pass http://cyclone-backend:8000; proxy_pass http://backend:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -243,7 +243,7 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /build/dist /usr/share/nginx/html COPY --from=builder /build/dist /usr/share/nginx/html
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8080/ >/dev/null || exit 1 CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1
EXPOSE 8080 EXPOSE 8080
``` ```
@@ -496,7 +496,7 @@ Create `scripts/post-deploy.sh`:
set -euo pipefail set -euo pipefail
LOG_DIR="/var/log/cyclone" LOG_DIR="/var/log/cyclone"
HEALTHCHECK_URL="http://localhost:8080/api/healthz" HEALTHCHECK_URL="http://127.0.0.1:8080/api/healthz"
HEALTHCHECK_EMAIL="${CYCLONE_HEALTHCHECK_EMAIL:-root}" HEALTHCHECK_EMAIL="${CYCLONE_HEALTHCHECK_EMAIL:-root}"
CRON_USER="${CYCLONE_CRON_USER:-root}" CRON_USER="${CYCLONE_CRON_USER:-root}"
@@ -653,13 +653,13 @@ Production operations for a single-operator Cyclone deploy on Ubuntu Linux. Assu
## Daily ## Daily
- [ ] Confirm the host healthcheck cron hasn't emailed. It pings `http://localhost:8080/api/healthz` every 5 minutes. - [ ] Confirm the host healthcheck cron hasn't emailed. It pings `http://127.0.0.1:8080/api/healthz` every 5 minutes.
- [ ] `docker compose ps` — both services `healthy`. - [ ] `docker compose ps` — both services `healthy`.
- [ ] `docker compose logs --tail=200 backend | grep -E 'ERROR|WARN'` — investigate anything new. - [ ] `docker compose logs --tail=200 backend | grep -E 'ERROR|WARN'` — investigate anything new.
## Weekly ## Weekly
- [ ] `curl -fsS http://localhost:8080/api/admin/audit-log -b cookies.txt | jq '.events[] | select(.event | test("login_failed|backup.failed"))'` — review failed logins + backup failures. - [ ] `curl -fsS http://127.0.0.1:8080/api/admin/audit-log -b cookies.txt | jq '.events[] | select(.event | test("login_failed|backup.failed"))'` — review failed logins + backup failures.
- [ ] Confirm `docker compose exec backend ls -la /var/lib/cyclone/backups/` shows recent `.bin` files (within 25h of now). - [ ] Confirm `docker compose exec backend ls -la /var/lib/cyclone/backups/` shows recent `.bin` files (within 25h of now).
## Quarterly ## Quarterly
@@ -1152,3 +1152,26 @@ Per spec §14 — not done in this plan:
- Watchtower / automatic updates. Manual `docker compose pull`. - Watchtower / automatic updates. Manual `docker compose pull`.
- Linting / pre-commit / dev tooling polish. - Linting / pre-commit / dev tooling polish.
- Component / E2E browser tests (Playwright). - Component / E2E browser tests (Playwright).
---
## Live verification (post-implementation, gated `DOCKER_TESTS=1`)
Before opening the PR, the full `docker compose up -d` stack was brought up on the dev host with `DOCKER_TESTS=1`. Six bugs surfaced that don't show up in unit tests — all fixed in the followup commit `3ba5ca0`:
1. **Wheel built from stub `__init__.py`** — the "stub init, wheel, copy src, wheel again" pattern silently kept the first wheel's contents. The installed package had an empty `__init__.py`, so `from cyclone import __version__` crashed at import time. Single `COPY src/` + single `pip wheel` is correct.
2. **Backend binds to `127.0.0.1`** — intentional (local-only by design, see CLAUDE.md). But that means the frontend container can't reach it over the compose bridge. Added `CYCLONE_HOST` env var, defaults to `127.0.0.1`, set to `0.0.0.0` in compose.
3. **Healthcheck probed `/api/healthz` (404)** — actual route is `/api/health`. Updated Dockerfile, compose, RUNBOOK, post-deploy.sh, smoke.sh.
4. **Auth matrix had `("/api/healthz"): set()`** — wrong path; would have deny-by-defaulted the real health endpoint. Updated to `/api/health`.
5. **nginx upstream `cyclone-backend:8000`** — compose v2 only resolves the bare service name over the bridge network. Updated to `backend:8000`.
6. **Frontend healthcheck `http://localhost:8080/`** — alpine nginx listens on IPv6 by default; `localhost` is unreliable. Updated to `http://127.0.0.1:8080/`.
Live verification results (with `/tmp/cyclone-test-secrets/` + override, nocodb stopped):
- Both containers `(healthy)` within ~60s
- `curl http://localhost:8080/api/health` → 200, valid JSON
- `POST /api/auth/login` as admin → 200, cookie set
- `GET /api/auth/me` → 200 with admin user
- `POST /api/parse-837` with `docs/goodclaim.x12` → 200, batch `6252a9265ea943039d363bbca2c16059` created
- Full backend test suite (`DOCKER_TESTS=1`): **1026 passed, 9 skipped** (skips are prodfile corpus, gitignored)
Plus a followup commit `aecf831` to the live bring-up test itself: uses the override file when present, skips cleanly when port 8080 is bound by another service on a dev workstation.
@@ -139,7 +139,7 @@ After this ships, the operator can:
**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. **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`. **Container-to-container networking:** the frontend container reaches the backend at `http://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. **Healthcheck:** container-level `curl -fs http://localhost:8000/api/healthz` every 30s, 3 retries. Compose `restart: unless-stopped` on healthcheck failure.
@@ -155,7 +155,7 @@ server {
# API + auth: proxy to backend # API + auth: proxy to backend
location /api/ { location /api/ {
proxy_pass http://cyclone-backend:8000; proxy_pass http://backend:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -455,7 +455,7 @@ FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /build/dist /usr/share/nginx/html COPY --from=builder /build/dist /usr/share/nginx/html
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8080/ >/dev/null || exit 1 CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1
``` ```
The nginx config shown in §5 lives at `frontend/nginx.conf`. The nginx config shown in §5 lives at `frontend/nginx.conf`.
@@ -538,7 +538,7 @@ v1 assumes the host is physically secure (single-operator server, locked room) a
### 9.3 Monitoring (v1 minimal) ### 9.3 Monitoring (v1 minimal)
- No Prometheus/Grafana in v1 - 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` - Host-level cron: `curl -fs http://127.0.0.1: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 - Set up by `scripts/post-deploy.sh` during initial bootstrap
### 9.4 Updates ### 9.4 Updates
@@ -710,25 +710,25 @@ docker compose up -d --build
# 2. Wait for healthcheck # 2. Wait for healthcheck
for i in {1..30}; do for i in {1..30}; do
if curl -fs http://localhost:8080/api/healthz > /dev/null; then break; fi if curl -fs http://127.0.0.1:8080/api/healthz > /dev/null; then break; fi
sleep 2 sleep 2
done done
# 3. Login # 3. Login
COOKIE_JAR=$(mktemp) COOKIE_JAR=$(mktemp)
curl -fs -c "$COOKIE_JAR" -X POST http://localhost:8080/api/auth/login \ curl -fs -c "$COOKIE_JAR" -X POST http://127.0.0.1:8080/api/auth/login \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d "{\"username\":\"admin\",\"password\":\"$(cat /etc/cyclone/secrets/admin_pw)\"}" -d "{\"username\":\"admin\",\"password\":\"$(cat /etc/cyclone/secrets/admin_pw)\"}"
# 4. Parse a sample 837 # 4. Parse a sample 837
curl -fs -b "$COOKIE_JAR" -X POST http://localhost:8080/api/parse-837 \ curl -fs -b "$COOKIE_JAR" -X POST http://127.0.0.1:8080/api/parse-837 \
-F "file=@docs/goodclaim.x12" -F "file=@docs/goodclaim.x12"
# 5. List batches # 5. List batches
curl -fs -b "$COOKIE_JAR" http://localhost:8080/api/batches curl -fs -b "$COOKIE_JAR" http://127.0.0.1:8080/api/batches
# 6. Export # 6. Export
curl -fs -b "$COOKIE_JAR" -X POST http://localhost:8080/api/batches/<id>/export-837 \ curl -fs -b "$COOKIE_JAR" -X POST http://127.0.0.1:8080/api/batches/<id>/export-837 \
-H 'Content-Type: application/json' -d '{"claim_ids":["..."]}' \ -H 'Content-Type: application/json' -d '{"claim_ids":["..."]}' \
-o /tmp/export.zip -o /tmp/export.zip
@@ -797,7 +797,7 @@ DB migrations run automatically on backend start; they're forward-only. To roll
- [ ] `docker compose config` validates with no errors - [ ] `docker compose config` validates with no errors
- [ ] `docker compose build` succeeds for both services - [ ] `docker compose build` succeeds for both services
- [ ] `docker compose up -d` brings both containers to `healthy` within 60s - [ ] `docker compose up -d` brings both containers to `healthy` within 60s
- [ ] `curl http://localhost:8080/api/healthz` returns `{db_ok: true, scheduler_running: true}` - [ ] `curl http://127.0.0.1:8080/api/healthz` returns `{db_ok: true, scheduler_running: true}`
### 15.2 Auth + RBAC ### 15.2 Auth + RBAC
-10
View File
@@ -1,10 +0,0 @@
node_modules/
dist/
build/
*.tsbuildinfo
coverage/
src/**/*.test.ts
src/**/*.test.tsx
src/test/
.git/
.github/
-33
View File
@@ -1,33 +0,0 @@
# syntax=docker/dockerfile:1.7
#
# Cyclone frontend — React SPA built with node:20-alpine and served by
# nginx:1.27-alpine. nginx reverse-proxies /api/* to the backend service
# over the compose-managed bridge network.
# ---------- builder ----------
FROM node:20-alpine AS builder
WORKDIR /build
# Install deps first so this layer caches across source edits.
COPY package.json package-lock.json* ./
RUN npm ci
# Build the production bundle into dist/.
COPY . .
RUN npm run build
# ---------- runtime ----------
FROM nginx:1.27-alpine
# Replace the default nginx site with ours (SPA + reverse proxy).
RUN rm -f /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /build/dist /usr/share/nginx/html
# wget is on busybox; nginx:alpine doesn't ship curl.
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8080/ >/dev/null || exit 1
EXPOSE 8080
+1 -1
View File
@@ -15,7 +15,7 @@ server {
# API + auth: proxy to backend over the compose-managed bridge network. # API + auth: proxy to backend over the compose-managed bridge network.
location /api/ { location /api/ {
proxy_pass http://cyclone-backend:8000; proxy_pass http://backend:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "cyclone", "name": "cyclone",
"private": true, "private": true,
"version": "0.1.0", "version": "1.0.0",
"type": "module", "type": "module",
"description": "Cyclone — self-hosted EDI claims management frontend (CuNtx)", "description": "Cyclone — self-hosted EDI claims management frontend (CuNtx)",
"scripts": { "scripts": {
+3 -3
View File
@@ -4,7 +4,7 @@
set -euo pipefail set -euo pipefail
LOG_DIR="/var/log/cyclone" LOG_DIR="/var/log/cyclone"
HEALTHCHECK_URL="${CYCLONE_HEALTHCHECK_URL:-http://localhost:8080/api/healthz}" HEALTHCHECK_URL="${CYCLONE_HEALTHCHECK_URL:-http://localhost:8080/api/health}"
HEALTHCHECK_EMAIL="${CYCLONE_HEALTHCHECK_EMAIL:-root}" HEALTHCHECK_EMAIL="${CYCLONE_HEALTHCHECK_EMAIL:-root}"
CRON_USER="${CYCLONE_CRON_USER:-root}" CRON_USER="${CYCLONE_CRON_USER:-root}"
@@ -32,11 +32,11 @@ mkdir -p "${LOG_DIR}"
chmod 755 "${LOG_DIR}" chmod 755 "${LOG_DIR}"
# 2. Healthcheck cron — every 5 minutes, email if down. # 2. Healthcheck cron — every 5 minutes, email if down.
CRON_LINE="*/5 * * * * curl -fsS --max-time 10 ${HEALTHCHECK_URL} >/dev/null 2>&1 || echo 'Cyclone DOWN at \$(date)' | mail -s 'Cyclone healthz FAIL' ${HEALTHCHECK_EMAIL}" CRON_LINE="*/5 * * * * curl -fsS --max-time 10 ${HEALTHCHECK_URL} >/dev/null 2>&1 || echo 'Cyclone DOWN at \$(date)' | mail -s 'Cyclone health FAIL' ${HEALTHCHECK_EMAIL}"
TMP_CRON="$(mktemp)" TMP_CRON="$(mktemp)"
crontab -u "${CRON_USER}" -l 2>/dev/null > "${TMP_CRON}" || true crontab -u "${CRON_USER}" -l 2>/dev/null > "${TMP_CRON}" || true
# Remove any existing cyclone healthcheck line, then re-add. # Remove any existing cyclone healthcheck line, then re-add.
grep -v -F "Cyclone healthz" "${TMP_CRON}" > "${TMP_CRON}.new" || cp "${TMP_CRON}" "${TMP_CRON}.new" grep -v -F "Cyclone health" "${TMP_CRON}" > "${TMP_CRON}.new" || cp "${TMP_CRON}" "${TMP_CRON}.new"
echo "${CRON_LINE}" >> "${TMP_CRON}.new" echo "${CRON_LINE}" >> "${TMP_CRON}.new"
crontab -u "${CRON_USER}" "${TMP_CRON}.new" crontab -u "${CRON_USER}" "${TMP_CRON}.new"
rm -f "${TMP_CRON}" "${TMP_CRON}.new" rm -f "${TMP_CRON}" "${TMP_CRON}.new"
+3 -3
View File
@@ -19,11 +19,11 @@ SAMPLE_837="${CYCLONE_SMOKE_SAMPLE:-docs/prodfiles/co_medicaid/sample_837p.txt}"
cleanup() { rm -f "${COOKIE_JAR}" /tmp/cyclone_smoke_export.zip; } cleanup() { rm -f "${COOKIE_JAR}" /tmp/cyclone_smoke_export.zip; }
trap cleanup EXIT trap cleanup EXIT
echo "==> waiting for healthz..." echo "==> waiting for health..."
for i in {1..30}; do for i in {1..30}; do
if curl -fsS "${BASE_URL}/api/healthz" >/dev/null 2>&1; then break; fi if curl -fsS "${BASE_URL}/api/health" >/dev/null 2>&1; then break; fi
sleep 2 sleep 2
[[ $i -eq 30 ]] && { echo "FAIL: healthz never came up"; exit 1; } [[ $i -eq 30 ]] && { echo "FAIL: health never came up"; exit 1; }
done done
echo " ok" echo " ok"
+8
View File
@@ -171,6 +171,14 @@ export interface BatchSummary {
inputFilename: string; inputFilename: string;
parsedAt: string; parsedAt: string;
claimCount: number; claimCount: number;
/**
* Per-claim ids for 837P batches. Empty for 835 (no re-export
* endpoint) the UI hides the one-click Re-export button when
* this is empty. Lets the History tab call
* `POST /api/batches/{id}/export-837` directly with the row's ids
* instead of an extra round-trip to `/api/batches/{id}`.
*/
claimIds: string[];
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+531
View File
@@ -0,0 +1,531 @@
// @vitest-environment happy-dom
// Tell React this is an `act`-aware test environment so react-query's
// internal state updates flush through without noisy console warnings.
(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, useLocation } from "react-router-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Upload } from "./Upload";
import { useAppStore } from "@/store";
import type { ParsedBatch } from "@/types";
import type { BatchSummary } from "@/lib/api";
// Mock the api surface so the History tab never touches the network.
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
api: {
...actual.api,
isConfigured: true,
listBatches: vi.fn(),
exportBatch837: vi.fn(),
},
};
});
import { api } from "@/lib/api";
// The Re-export ZIP button ultimately calls `downloadBlob(filename, blob)`
// from `@/lib/download`; that helper mutates the DOM (creates a link and
// clicks it). Stub the whole module so we never touch the real DOM and
// the test can assert the filename we asked to be downloaded.
vi.mock("@/lib/download", () => ({
downloadBlob: vi.fn(),
downloadTextFile: vi.fn(),
}));
import { downloadBlob } from "@/lib/download";
// The Re-export path toasts via sonner. Mock it to keep the test output
// quiet and let assertions about success/error fire without spam.
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
message: vi.fn(),
},
}));
import { toast } from "sonner";
// The Upload page also relies on useAuth (via RoleGate). Mock it to an
// authenticated admin so the parse affordances render — we don't test
// RoleGate behavior here, only the History tab.
vi.mock("@/auth/useAuth", () => ({
useAuth: () => ({
user: { id: 1, username: "test", role: "admin" },
status: "authenticated",
}),
}));
// ---------------------------------------------------------------------------
// Test harness — Upload page lives behind a router (for useSearchParams)
// AND a QueryClient (for useBatches). Wrap both.
// ---------------------------------------------------------------------------
interface MountOpts {
initialEntries?: string[];
/** Pre-populated query data for `api.listBatches`. */
batches?: BatchSummary[];
/** Whether listBatches should resolve or reject. */
batchesStatus?: "success" | "error" | "pending";
}
interface Harness {
container: HTMLDivElement;
unmount: () => void;
tracker: { pathname: string; search: string };
}
function mountUpload(opts: MountOpts = {}): Harness {
const {
initialEntries = ["/upload"],
batches = [],
batchesStatus = "success",
} = opts;
// Wire the listBatches mock based on the requested status.
const listBatchesMock = api.listBatches as unknown as ReturnType<
typeof vi.fn
>;
listBatchesMock.mockReset();
if (batchesStatus === "success") {
listBatchesMock.mockResolvedValue(batches);
} else if (batchesStatus === "error") {
listBatchesMock.mockRejectedValue(new Error("network down"));
} else {
// pending — never resolve
listBatchesMock.mockReturnValue(new Promise(() => {}));
}
const container = document.createElement("div");
document.body.appendChild(container);
const tracker = { pathname: "/upload", search: "" };
const Tracker = () => {
const loc = useLocation();
React.useEffect(() => {
tracker.pathname = loc.pathname;
tracker.search = loc.search;
}, [loc.pathname, loc.search]);
return null;
};
const qc = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const root: Root = createRoot(container);
act(() => {
root.render(
React.createElement(
MemoryRouter,
{ initialEntries },
React.createElement(
QueryClientProvider,
{ client: qc },
React.createElement(Tracker),
React.createElement(Upload),
),
),
);
});
return {
container,
unmount: () => {
act(() => root.unmount());
container.remove();
},
tracker,
};
}
async function flush(): Promise<void> {
// React Query schedules its settled state through a microtask +
// setTimeout chain. Two ticks is the minimum to settle a mocked
// promise end-to-end; three to be safe across happy-dom
// implementations.
for (let i = 0; i < 3; i++) {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
}
/**
* Poll a predicate until it holds or we time out. Used for assertions
* that have to wait for the query to settle (which takes more than a
* single `flush()` in some happy-dom runs).
*/
async function settle(
predicate: () => boolean,
timeoutMs = 2000,
): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error("settle: predicate did not hold within timeout");
}
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
}
/**
* Click a Radix Tabs.Trigger in happy-dom. Radix listens for
* `pointerdown` (not just `click`) to fire its onValueChange handler,
* so the synthetic event sequence matters.
*/
async function clickTab(trigger: HTMLElement): Promise<void> {
await act(async () => {
trigger.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
pointerType: "mouse",
}),
);
trigger.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true, cancelable: true }),
);
trigger.dispatchEvent(
new MouseEvent("mouseup", { bubbles: true, cancelable: true }),
);
trigger.click();
await flush();
});
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const BATCH_837_A: BatchSummary = {
id: "BATCH-A",
kind: "837p",
inputFilename: "tp-001-837P.txt",
parsedAt: "2026-06-23T12:00:00Z",
claimCount: 12,
claimIds: ["CLM-001", "CLM-002", "CLM-003"],
};
const BATCH_837_B: BatchSummary = {
id: "BATCH-B",
kind: "837p",
inputFilename: "tp-002-837P.txt",
parsedAt: "2026-06-22T12:00:00Z",
claimCount: 7,
claimIds: ["CLM-101", "CLM-102"],
};
const BATCH_835: BatchSummary = {
id: "BATCH-C",
kind: "835",
inputFilename: "tp-003-835.txt",
parsedAt: "2026-06-21T12:00:00Z",
claimCount: 4,
claimIds: [], // 835 has no re-export endpoint
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Upload page — History tab", () => {
beforeEach(() => {
useAppStore.setState({ parsedBatches: [] });
vi.mocked(downloadBlob).mockReset();
vi.mocked(toast.success).mockReset();
vi.mocked(toast.error).mockReset();
});
afterEach(() => {
useAppStore.setState({ parsedBatches: [] });
});
it("renders the Upload tab by default and shows the History trigger with a count badge", async () => {
const { container, unmount } = mountUpload({
batches: [BATCH_837_A, BATCH_837_B],
});
await settle(() => container.textContent?.includes("· 2") === true);
// Both triggers are present.
const triggers = container.querySelectorAll('[role="tab"]');
expect(triggers.length).toBeGreaterThanOrEqual(2);
expect(container.textContent).toContain("Upload");
expect(container.textContent).toContain("History");
// Count badge shows the persisted-batch count.
expect(container.textContent).toContain("· 2");
unmount();
});
it("clicking the History trigger shows the batch table and hides the dropzone", async () => {
const { container, unmount } = mountUpload({
batches: [BATCH_837_A, BATCH_837_B],
});
await flush();
// Before click: the dropzone is rendered.
expect(
container.querySelector('[aria-label="File upload"]'),
).not.toBeNull();
// Click the History trigger.
const historyTrigger = Array.from(
container.querySelectorAll('[role="tab"]'),
).find((el) => el.textContent?.startsWith("History")) as HTMLElement;
expect(historyTrigger).toBeDefined();
await clickTab(historyTrigger);
// Now the dropzone is hidden and the history table is visible.
expect(container.querySelector('[aria-label="File upload"]')).toBeNull();
expect(container.textContent).toContain("Batch history");
expect(container.textContent).toContain("tp-001-837P.txt");
expect(container.textContent).toContain("tp-002-837P.txt");
unmount();
});
it("clicking the History trigger mirrors ?tab=history in the URL", async () => {
const { container, unmount, tracker } = mountUpload({
batches: [BATCH_837_A],
});
await flush();
const historyTrigger = Array.from(
container.querySelectorAll('[role="tab"]'),
).find((el) => el.textContent?.startsWith("History")) as HTMLElement;
await clickTab(historyTrigger);
expect(tracker.search).toContain("tab=history");
unmount();
});
it("?tab=history in the initial URL deep-links directly to the History tab", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batches: [BATCH_837_A, BATCH_837_B],
});
await flush();
// Dropzone is hidden — we landed on the History tab.
expect(container.querySelector('[aria-label="File upload"]')).toBeNull();
expect(container.textContent).toContain("Batch history");
expect(container.textContent).toContain("tp-001-837P.txt");
unmount();
});
it("renders one row per batch with sequential # column", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batches: [BATCH_837_A, BATCH_837_B, BATCH_835],
});
await settle(() =>
container.querySelectorAll("tbody tr").length === 3,
);
const rows = container.querySelectorAll("tbody tr");
expect(rows.length).toBe(3);
// Newest first → #03 (BATCH-A), #02 (BATCH-B), #01 (BATCH-C).
// The # column counts down from the total so the newest row reads
// as the highest ordinal.
expect(rows[0]?.textContent).toContain("#03");
expect(rows[0]?.textContent).toContain("tp-001-837P.txt");
expect(rows[1]?.textContent).toContain("#02");
expect(rows[1]?.textContent).toContain("tp-002-837P.txt");
expect(rows[2]?.textContent).toContain("#01");
expect(rows[2]?.textContent).toContain("tp-003-835.txt");
unmount();
});
it("renders a Re-export ZIP button for each 837P row", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batches: [BATCH_837_A, BATCH_837_B, BATCH_835],
});
await settle(
() =>
container.querySelectorAll('[data-testid="history-row-reexport"]')
.length === 2,
);
const buttons = container.querySelectorAll(
'[data-testid="history-row-reexport"]',
);
// Two 837P rows get a button; the 835 row does not.
expect(buttons.length).toBe(2);
unmount();
});
it("hides the Re-export button on 835 rows (no claimIds)", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batches: [BATCH_835],
});
await settle(() =>
container.textContent?.includes("No re-export") === true,
);
expect(
container.querySelector('[data-testid="history-row-reexport"]'),
).toBeNull();
// A muted "No re-export" placeholder is shown instead.
expect(container.textContent).toContain("No re-export");
unmount();
});
it("clicking Re-export ZIP calls api.exportBatch837 with the row's claimIds and downloads the blob", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batches: [BATCH_837_A],
});
await settle(
() =>
container.querySelector('[data-testid="history-row-reexport"]') !==
null,
);
const exportMock = api.exportBatch837 as unknown as ReturnType<
typeof vi.fn
>;
exportMock.mockResolvedValue({
blob: new Blob(["zip-bytes"]),
filename: "BATCH-A-claims.zip",
serializeErrors: [],
});
const button = container.querySelector(
'[data-testid="history-row-reexport"]',
) as HTMLButtonElement;
expect(button).not.toBeNull();
await act(async () => {
button.click();
await flush();
});
expect(exportMock).toHaveBeenCalledTimes(1);
expect(exportMock).toHaveBeenCalledWith("BATCH-A", [
"CLM-001",
"CLM-002",
"CLM-003",
]);
expect(downloadBlob).toHaveBeenCalledTimes(1);
expect(downloadBlob).toHaveBeenCalledWith(
"BATCH-A-claims.zip",
expect.any(Blob),
);
expect(toast.success).toHaveBeenCalledTimes(1);
unmount();
});
it("surfaces a toast.error when re-export fails", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batches: [BATCH_837_A],
});
await settle(
() =>
container.querySelector('[data-testid="history-row-reexport"]') !==
null,
);
const exportMock = api.exportBatch837 as unknown as ReturnType<
typeof vi.fn
>;
exportMock.mockRejectedValue(new Error("server gone"));
const button = container.querySelector(
'[data-testid="history-row-reexport"]',
) as HTMLButtonElement;
await act(async () => {
button.click();
await flush();
});
expect(toast.error).toHaveBeenCalledTimes(1);
expect(toast.success).not.toHaveBeenCalled();
expect(downloadBlob).not.toHaveBeenCalled();
unmount();
});
it("shows a loading state on first paint while the query is pending", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batchesStatus: "pending",
});
await flush();
expect(container.textContent).toContain("Loading archive");
// No rows yet.
expect(
container.querySelector('[data-testid="history-row-reexport"]'),
).toBeNull();
unmount();
});
it("shows an error state when the query rejects", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batchesStatus: "error",
});
await settle(() => container.textContent?.includes("Couldn't load") === true);
expect(container.textContent).toContain("Couldn't load the archive");
unmount();
});
it("shows an empty state when there are no batches", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batches: [],
});
await flush();
expect(container.textContent).toContain("No batches in the archive");
unmount();
});
it("falls back to in-memory parsedBatches when the backend returns no rows (sample-data mode)", async () => {
const sample: ParsedBatch = {
id: "MEM-1",
kind: "837p",
inputFilename: "mem-batch.txt",
parsedAt: "2026-06-24T12:00:00Z",
claimCount: 2,
passed: 2,
failed: 0,
claimIds: ["SAMPLE-1", "SAMPLE-2"],
summary: {
input_file: "mem-batch.txt",
total_claims: 2,
passed: 2,
failed: 0,
failed_claim_ids: [],
issues_by_rule: {},
},
};
useAppStore.setState({ parsedBatches: [sample] });
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
// Server returned nothing — fallback path activates.
batches: [],
});
await settle(() =>
container.textContent?.includes("mem-batch.txt") === true,
);
expect(container.textContent).toContain("mem-batch.txt");
expect(
container.querySelector('[data-testid="history-row-reexport"]'),
).not.toBeNull();
unmount();
});
});
+330 -2
View File
@@ -1,11 +1,13 @@
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { import {
AlertTriangle, AlertTriangle,
ArrowRight, ArrowRight,
ChevronRight, ChevronRight,
CloudUpload, CloudUpload,
Download,
FileText, FileText,
History as HistoryIcon,
Inbox, Inbox,
Loader2, Loader2,
Upload as UploadIcon, Upload as UploadIcon,
@@ -16,6 +18,7 @@ import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -27,11 +30,13 @@ import { PageHeader } from "@/components/PageHeader";
import { ClaimCard837 } from "@/components/ClaimCard837"; import { ClaimCard837 } from "@/components/ClaimCard837";
import { ExportBar } from "@/components/ExportBar"; import { ExportBar } from "@/components/ExportBar";
import { StatPill, ValidationDot } from "@/components/ClaimCard/shared"; import { StatPill, ValidationDot } from "@/components/ClaimCard/shared";
import { api, type ParseProgress } from "@/lib/api"; import { api, ApiError, type BatchSummary, type ParseProgress } from "@/lib/api";
import { downloadBlob } from "@/lib/download";
import { fmt, toNum } from "@/lib/format"; import { fmt, toNum } from "@/lib/format";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
import { useParse } from "@/hooks/useParse"; import { useParse } from "@/hooks/useParse";
import { useBatchExport } from "@/hooks/useBatchExport"; import { useBatchExport } from "@/hooks/useBatchExport";
import { useBatches } from "@/hooks/useBatches";
import type { import type {
ClaimOutput, ClaimOutput,
ClaimPayment, ClaimPayment,
@@ -348,6 +353,21 @@ function HeroStat({
export function Upload() { export function Upload() {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [searchParams, setSearchParams] = useSearchParams();
// Tab URL state — `?tab=history` deep-links the History tab;
// `?tab=upload` (or missing) is the default. Stored in the URL so
// the back button round-trips between the two surfaces.
const tab = searchParams.get("tab") === "history" ? "history" : "upload";
const setTab = (next: "upload" | "history") => {
setSearchParams(
(prev) => {
if (next === "upload") prev.delete("tab");
else prev.set("tab", next);
return prev;
},
{ replace: true },
);
};
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [kind, setKind] = useState<ParsedBatchKind>("837p"); const [kind, setKind] = useState<ParsedBatchKind>("837p");
const [payer, setPayer] = useState<string>(PAYERS_837[0]!.value); const [payer, setPayer] = useState<string>(PAYERS_837[0]!.value);
@@ -362,6 +382,14 @@ export function Upload() {
const parsedBatches = useAppStore((s) => s.parsedBatches); const parsedBatches = useAppStore((s) => s.parsedBatches);
const parseMutation = useParse(kind); const parseMutation = useParse(kind);
// Persisted batch count for the History tab badge. Disabled when
// there's no backend configured (sample-data mode); in that case we
// fall back to the in-memory parsedBatches so the badge still reads.
const batchesQuery = useBatches();
const persistedBatchCount = batchesQuery.data?.length ?? 0;
const historyCount =
persistedBatchCount > 0 ? persistedBatchCount : parsedBatches.length;
// Batch-export wiring (SP9 — Upload → ZIP flow). The hook owns the // Batch-export wiring (SP9 — Upload → ZIP flow). The hook owns the
// selection set, the exporting flag, the export handler, and the // selection set, the exporting flag, the export handler, and the
// captured server-side batch id. See `src/hooks/useBatchExport.ts`. // captured server-side batch id. See `src/hooks/useBatchExport.ts`.
@@ -607,6 +635,34 @@ export function Upload() {
</div> </div>
</section> </section>
{/* =================================================================
TABS the page's content switcher. Two surfaces:
· Upload drop zone + stream + recent batches (the
"in-flight" instrument)
· History persisted batch archive with one-click
Re-export ZIP per row
Tab is mirrored to `?tab=` so deep-links round-trip.
================================================================= */}
<Tabs.Root
value={tab}
onValueChange={(v) => setTab(v as "upload" | "history")}
>
<Tabs.List aria-label="Upload page sections">
<Tabs.Trigger value="upload">Upload</Tabs.Trigger>
<Tabs.Trigger value="history">
History
{historyCount > 0 ? (
<span
aria-hidden
className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/60 ml-1.5"
>
· {historyCount}
</span>
) : null}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="upload">
{/* ================================================================= {/* =================================================================
DROP ZONE the page's centerpiece. Single large surface-2 DROP ZONE the page's centerpiece. Single large surface-2
card with an inline payer-config header bar, a centered drop card with an inline payer-config header bar, a centered drop
@@ -1113,6 +1169,278 @@ export function Upload() {
</Card> </Card>
</section> </section>
) : null} ) : null}
</Tabs.Content>
<Tabs.Content value="history">
<UploadHistory />
</Tabs.Content>
</Tabs.Root>
</div> </div>
); );
} }
// ---------------------------------------------------------------------------
// UploadHistory — persisted batch archive rendered inside the History tab.
//
// Source: `useBatches()` (the backend's `/api/batches` list). Independent of
// the in-memory `parsedBatches` store so it survives a page reload — the
// store is purely session-local. When the backend isn't configured (sample-
// data mode) we fall back to the in-memory list so the tab still renders.
// ---------------------------------------------------------------------------
function UploadHistory() {
const parsedBatches = useAppStore((s) => s.parsedBatches);
const batchesQuery = useBatches();
const liveBatches: BatchSummary[] = useMemo(() => {
if (batchesQuery.data && batchesQuery.data.length > 0) {
return batchesQuery.data;
}
// Sample-data fallback — synthesize BatchSummary rows from the
// in-memory parsedBatches so the History tab is never empty in
// a demo. Newest first.
return [...parsedBatches]
.reverse()
.map<BatchSummary>((b) => ({
id: b.id,
kind: b.kind,
inputFilename: b.inputFilename,
parsedAt: b.parsedAt,
claimCount: b.claimCount,
claimIds: b.claimIds,
}));
}, [batchesQuery.data, parsedBatches]);
if (batchesQuery.isLoading && batchesQuery.data === undefined) {
return (
<Card>
<CardContent className="p-10 lg:p-14 flex flex-col items-center justify-center text-center">
<Loader2
className="h-4 w-4 animate-spin text-muted-foreground"
aria-hidden
/>
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70 mt-3">
Loading archive
</div>
</CardContent>
</Card>
);
}
if (batchesQuery.isError) {
return (
<Card>
<CardContent className="p-10 lg:p-14 flex flex-col items-center justify-center text-center">
<XCircle
className="h-5 w-5 text-[hsl(var(--destructive))]"
strokeWidth={1.5}
aria-hidden
/>
<div className="display text-[18px] tracking-tight mt-3">
Couldn't load the archive.
</div>
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70 mt-2">
{batchesQuery.error instanceof Error
? batchesQuery.error.message
: "Network error"}
</div>
</CardContent>
</Card>
);
}
if (liveBatches.length === 0) {
return (
<Card>
<CardContent className="p-10 lg:p-14 flex flex-col items-center justify-center text-center">
<div className="h-10 w-10 rounded-md bg-muted/50 ring-1 ring-inset ring-border/60 flex items-center justify-center text-muted-foreground mb-3">
<HistoryIcon className="h-4 w-4" strokeWidth={1.5} />
</div>
<div className="display text-[20px] tracking-tight">
No batches in the archive yet.
</div>
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70 mt-2">
Switch to Upload and drop a file to ingest your first batch.
</div>
</CardContent>
</Card>
);
}
return <HistoryTable batches={liveBatches} />;
}
function HistoryTable({ batches }: { batches: BatchSummary[] }) {
const totalClaims = batches.reduce((s, b) => s + b.claimCount, 0);
const lastAt = batches[0] ? fmt.dateShort(batches[0].parsedAt) : "—";
return (
<Card>
<CardContent className="p-6 lg:p-7">
<div className="flex items-end justify-between gap-6 flex-wrap mb-5">
<div>
<div className="eyebrow flex items-center gap-2 mb-2">
<span className="inline-block h-px w-6 bg-foreground/20" />
Batch history
</div>
<h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
Archive{" "}
<span className="italic text-muted-foreground/85">
· {batches.length}
</span>
</h2>
</div>
<div className="text-right">
<div className="mono text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70">
Last ingest
</div>
<div className="display mono text-[22px] leading-[1.05] mt-1.5 tracking-tight">
{lastAt}
</div>
<div className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/60 mt-1">
{fmt.num(totalClaims)} claims
</div>
</div>
</div>
<div
className="rounded-md border overflow-hidden"
style={{ borderColor: "hsl(30 14% 14% / 0.10)" }}
>
<table className="w-full text-[12.5px]">
<thead style={{ backgroundColor: "hsl(36 22% 90%)" }}>
<tr
className="text-left"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-12">
#
</th>
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-20">
Kind
</th>
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px]">
File
</th>
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-20 text-right">
Claims
</th>
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-28">
Parsed
</th>
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-44 text-right">
Action
</th>
</tr>
</thead>
<tbody>
{batches.map((b, i) => (
<HistoryRow
key={b.id}
batch={b}
index={batches.length - i}
/>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function HistoryRow({
batch,
index,
}: {
batch: BatchSummary;
index: number;
}) {
const [exporting, setExporting] = useState(false);
const canReexport = batch.kind === "837p" && batch.claimIds.length > 0;
async function onReexport() {
if (!canReexport || exporting) return;
setExporting(true);
try {
const result = await api.exportBatch837(batch.id, batch.claimIds);
downloadBlob(result.filename, result.blob);
const warn =
result.serializeErrors.length > 0
? ` · ${result.serializeErrors.length} skipped`
: "";
toast.success(`Re-exported ${batch.claimIds.length} claims${warn}`, {
description: result.filename,
});
} catch (err) {
toast.error(
err instanceof ApiError
? `Re-export failed (${err.status})`
: err instanceof Error
? err.message
: "Re-export failed",
);
} finally {
setExporting(false);
}
}
return (
<tr
className="border-t align-middle"
style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}
>
<td
className="px-3 py-2.5 mono text-[11px] text-muted-foreground/60"
>
#{String(index).padStart(2, "0")}
</td>
<td className="px-3 py-2.5">
<Badge variant={batch.kind === "837p" ? "default" : "muted"}>
{batch.kind === "837p" ? "837P" : "835"}
</Badge>
</td>
<td className="px-3 py-2.5">
<div className="font-medium truncate max-w-[42ch]">
{batch.inputFilename}
</div>
</td>
<td className="px-3 py-2.5 text-right display mono">
{fmt.num(batch.claimCount)}
</td>
<td
className="px-3 py-2.5 mono text-[11px]"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{fmt.dateShort(batch.parsedAt)}
</td>
<td className="px-3 py-2.5 text-right">
{canReexport ? (
<Button
variant="ghost"
size="sm"
onClick={onReexport}
disabled={exporting}
data-testid="history-row-reexport"
data-batch-id={batch.id}
>
{exporting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Exporting
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
Re-export ZIP
</>
)}
</Button>
) : (
<span className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/50">
No re-export
</span>
)}
</td>
</tr>
);
}