Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1860782ad6 | |||
| ce37c10c06 | |||
| f4bafc1c94 | |||
| 24fbf945c9 | |||
| 07a7ecbdd4 | |||
| 5334646992 | |||
| aecf831f43 | |||
| 3ba5ca0849 | |||
| f7697e58b7 | |||
| 7706a6d7fe | |||
| 256ddfa5fa | |||
| d42fbc8c1b | |||
| 67dae61a94 | |||
| 59e69127a2 | |||
| 364e5d7497 | |||
| 35c561de20 |
@@ -0,0 +1,13 @@
|
|||||||
|
# Repo-root .dockerignore — applies to `docker compose build` (which builds
|
||||||
|
# both backend and frontend contexts from the repo root). Excludes anything
|
||||||
|
# that should never end up in a build context.
|
||||||
|
|
||||||
|
.worktrees/
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
docs/prodfiles/
|
||||||
|
*.production.txt
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.venv/
|
||||||
|
.superpowers/brainstorm/
|
||||||
@@ -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
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
# Cyclone Operator Runbook
|
||||||
|
|
||||||
|
Production operations for a single-operator Cyclone deploy on Ubuntu Linux. Assumes the box was bootstrapped via `scripts/cyclone-init.sh` and the stack is up via `docker compose up -d`.
|
||||||
|
|
||||||
|
## Daily
|
||||||
|
|
||||||
|
- [ ] 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 logs --tail=200 backend | grep -E 'ERROR|WARN'` — investigate anything new.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- [ ] Confirm `docker compose exec backend ls -la /var/lib/cyclone/backups/` shows recent `.bin` files (within 25h of now).
|
||||||
|
|
||||||
|
## Quarterly
|
||||||
|
|
||||||
|
- [ ] Rotate the SQLCipher / cookie-signing key:
|
||||||
|
```bash
|
||||||
|
bash scripts/cyclone-init.sh --force # overwrites /etc/cyclone/secrets/db.key
|
||||||
|
docker compose restart backend # picks up the new key
|
||||||
|
```
|
||||||
|
Old `.bin` backups become unreadable after this; export them first if you need to keep them.
|
||||||
|
|
||||||
|
## As needed
|
||||||
|
|
||||||
|
- **Add an operator.** Log in as admin → `/admin/users` → Create user. Roles: `admin` / `user` / `viewer`.
|
||||||
|
- **Reset a password.** Admin UI → Users → Reset password, OR `docker compose exec backend python -m cyclone admin reset-password --username <name>`.
|
||||||
|
- **Restore from backup.** Admin UI → Backups → pick the snapshot → Initiate restore → Confirm. The backend will restart automatically.
|
||||||
|
- **Roll back the code (not the schema).** `TAG=0.0.9 docker compose up -d`. The previous image stays in the local Docker cache for one cycle.
|
||||||
|
- **Pull a new `:stable`.**
|
||||||
|
```bash
|
||||||
|
cd /opt/cyclone
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs -f backend | head -200 # verify migrations + healthcheck
|
||||||
|
```
|
||||||
|
- **Off-box backup copy.** The operator is expected to rsync `/var/lib/docker/volumes/cyclone_backups/_data/` to an external drive or NAS nightly. The `.bin` files are already encrypted; the destination doesn't need its own encryption.
|
||||||
|
- **Inspect the DB.** `docker compose exec backend sqlite3 /var/lib/cyclone/db/cyclone.db ".tables"` (works only if SQLCipher key is on disk; the in-process decrypt happens via the cyclone backend).
|
||||||
|
|
||||||
|
## Annual
|
||||||
|
|
||||||
|
- [ ] Rotate the admin password (force re-login for everyone).
|
||||||
|
- [ ] Audit the `/etc/cyclone/secrets/` directory permissions — should be `chmod 600 root:root`.
|
||||||
|
- [ ] Review the audit log for stale admin sessions.
|
||||||
|
|
||||||
|
## Emergency
|
||||||
|
|
||||||
|
- **Backend won't start.** `docker compose logs --tail=300 backend`. Look for migration failures (rerun is safe — migrations are forward-only), SQLCipher key mismatch (`PRAGMA key` failure), or port collisions.
|
||||||
|
- **Frontend won't serve.** `docker compose logs --tail=100 frontend`. Usually nginx config drift; `docker compose restart frontend`.
|
||||||
|
- **Both unhealthy after a host reboot.** Docker may have come up before the named volumes did. `docker compose down && docker compose up -d`.
|
||||||
|
- **Suspected key compromise.** Rotate immediately (see Quarterly above). All active sessions are invalidated.
|
||||||
|
|
||||||
|
## Where things live
|
||||||
|
|
||||||
|
| Asset | Path |
|
||||||
|
|---|---|
|
||||||
|
| Docker compose file | `/opt/cyclone/docker-compose.yml` |
|
||||||
|
| Secrets | `/etc/cyclone/secrets/{db.key,admin_username,admin_pw}` |
|
||||||
|
| Live DB (SQLCipher-encrypted volume) | `cyclone_db` named volume, mounted at `/var/lib/cyclone/db` |
|
||||||
|
| Encrypted backups | `cyclone_backups` named volume, mounted at `/var/lib/cyclone/backups` |
|
||||||
|
| Uploaded prod files | `cyclone_prodfiles` named volume |
|
||||||
|
| SFTP staging stub | `cyclone_sftp_staging` named volume |
|
||||||
|
| Logs | `cyclone_logs` named volume + bind-mounted at `/var/log/cyclone` |
|
||||||
|
| Off-box backup destination | Operator's external drive / NAS (rsync cron, not in compose) |
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
tests/
|
||||||
|
docs/prodfiles/
|
||||||
|
*.production.txt
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# Cyclone backend — FastAPI on python:3.11-slim-bookworm with sqlcipher.
|
||||||
|
#
|
||||||
|
# Two-stage build:
|
||||||
|
# 1. builder — wheels the package with [sqlcipher] extra into /wheels.
|
||||||
|
# 2. runtime — slim base, tini PID 1, curl-based healthcheck.
|
||||||
|
#
|
||||||
|
# `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
|
||||||
|
# missing libsqlcipher-dev during build will fail loudly here rather than
|
||||||
|
# silently downgrading encryption in production.
|
||||||
|
|
||||||
|
# ---------- builder ----------
|
||||||
|
FROM python:3.11-slim-bookworm AS builder
|
||||||
|
|
||||||
|
ENV PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libffi-dev \
|
||||||
|
libsqlcipher-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy the build manifest first so this layer caches across source edits.
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
|
||||||
|
# Copy the full source tree, then build the wheel once. We deliberately
|
||||||
|
# avoid the "stub __init__.py, build wheel, then rebuild" pattern — it
|
||||||
|
# left stale `__init__.py` content in the wheel because pip wheel reuses
|
||||||
|
# the cached wheel metadata when the name+version matches. See git
|
||||||
|
# history on this file for the long version.
|
||||||
|
COPY src/ ./src/
|
||||||
|
RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher]'
|
||||||
|
|
||||||
|
# ---------- runtime ----------
|
||||||
|
FROM python:3.11-slim-bookworm
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
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 --shell /bin/bash cyclone
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /wheels /wheels
|
||||||
|
RUN pip install --no-cache-dir --no-index --find-links /wheels 'cyclone[sqlcipher]' \
|
||||||
|
&& rm -rf /wheels
|
||||||
|
|
||||||
|
# NOTE: we deliberately do NOT drop privileges to the `cyclone` user.
|
||||||
|
# Named volumes mount as root inside the container, and chown-ing them
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Container-level healthcheck — the compose service healthcheck is
|
||||||
|
# effectively a duplicate but the Docker `HEALTHCHECK` directive keeps
|
||||||
|
# `docker ps` honest without needing compose to be running.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD curl -fs http://localhost:8000/api/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["tini", "--"]
|
||||||
|
CMD ["python", "-m", "cyclone", "serve"]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ Precedence:
|
|||||||
2. Users table non-empty — no-op.
|
2. Users table non-empty — no-op.
|
||||||
3. ``CYCLONE_ADMIN_USERNAME`` + ``CYCLONE_ADMIN_PASSWORD`` env vars set
|
3. ``CYCLONE_ADMIN_USERNAME`` + ``CYCLONE_ADMIN_PASSWORD`` env vars set
|
||||||
(password >= 12 chars) — create the admin and print confirmation.
|
(password >= 12 chars) — create the admin and print confirmation.
|
||||||
|
Each env var can also be replaced by a ``*_FILE`` companion
|
||||||
|
(``CYCLONE_ADMIN_USERNAME_FILE`` / ``CYCLONE_ADMIN_PASSWORD_FILE``)
|
||||||
|
that points at a file on disk — the standard Docker-secret pattern,
|
||||||
|
used in production to avoid embedding secrets in ``docker-compose.yml``.
|
||||||
|
``_FILE`` takes precedence when set.
|
||||||
4. Otherwise — raise ``RuntimeError`` with a remediation hint that
|
4. Otherwise — raise ``RuntimeError`` with a remediation hint that
|
||||||
points operators at ``python -m cyclone users create``.
|
points operators at ``python -m cyclone users create``.
|
||||||
"""
|
"""
|
||||||
@@ -18,6 +23,7 @@ Precedence:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -27,6 +33,21 @@ from cyclone.auth.permissions import Role
|
|||||||
from cyclone.db import SessionLocal, User
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
def _read_secret(env_var: str, file_var: str) -> str | None:
|
||||||
|
"""Read a secret from a ``*_FILE`` env var (Docker-secret pattern) first,
|
||||||
|
falling back to the plain env var. Returns None if neither is set.
|
||||||
|
"""
|
||||||
|
file_path = os.environ.get(file_var)
|
||||||
|
if file_path:
|
||||||
|
try:
|
||||||
|
return Path(file_path).read_text().strip()
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"failed to read {file_var}={file_path}: {exc}"
|
||||||
|
) from exc
|
||||||
|
return os.environ.get(env_var)
|
||||||
|
|
||||||
|
|
||||||
def run() -> None:
|
def run() -> None:
|
||||||
"""Bootstrap the first admin user, or no-op.
|
"""Bootstrap the first admin user, or no-op.
|
||||||
|
|
||||||
@@ -42,8 +63,12 @@ def run() -> None:
|
|||||||
_deps.AUTH_DISABLED = True
|
_deps.AUTH_DISABLED = True
|
||||||
return
|
return
|
||||||
|
|
||||||
username = os.environ.get("CYCLONE_ADMIN_USERNAME")
|
username = _read_secret(
|
||||||
password = os.environ.get("CYCLONE_ADMIN_PASSWORD")
|
"CYCLONE_ADMIN_USERNAME", "CYCLONE_ADMIN_USERNAME_FILE"
|
||||||
|
)
|
||||||
|
password = _read_secret(
|
||||||
|
"CYCLONE_ADMIN_PASSWORD", "CYCLONE_ADMIN_PASSWORD_FILE"
|
||||||
|
)
|
||||||
|
|
||||||
# First-boot fix: ``python -m cyclone`` calls bootstrap before any
|
# First-boot fix: ``python -m cyclone`` calls bootstrap before any
|
||||||
# subcommand or the FastAPI lifespan handler runs, so on a brand-new
|
# subcommand or the FastAPI lifespan handler runs, so on a brand-new
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Auth bootstrap reads CYCLONE_ADMIN_*_FILE env vars when set.
|
||||||
|
|
||||||
|
Mirrors the Docker-secret posture in ``docker-compose.yml``: secrets
|
||||||
|
mounted at ``/run/secrets/<name>`` with the ``*_FILE`` env var pointing
|
||||||
|
at the path. The bootstrap should prefer the file over the bare env var
|
||||||
|
when both are present (file = Docker secret wins).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.auth import bootstrap, users
|
||||||
|
from cyclone.auth.permissions import Role
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||||
|
db_path = tmp_path / "test.db"
|
||||||
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{db_path}")
|
||||||
|
db.init_db()
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
def _write_secrets(tmp_path: Path, *, username: str, password: str) -> tuple[Path, Path]:
|
||||||
|
user_file = tmp_path / "admin_username"
|
||||||
|
pw_file = tmp_path / "admin_pw"
|
||||||
|
user_file.write_text(f"{username}\n")
|
||||||
|
pw_file.write_text(f"{password}\n")
|
||||||
|
return user_file, pw_file
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_creates_admin_from_file_env_vars(
|
||||||
|
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
user_file, pw_file = _write_secrets(
|
||||||
|
tmp_path, username="deploy-admin", password="super-secret-password-123"
|
||||||
|
)
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME_FILE", str(user_file))
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD_FILE", str(pw_file))
|
||||||
|
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
|
with db.SessionLocal()() as session:
|
||||||
|
user = users.get_by_username(session, "deploy-admin")
|
||||||
|
assert user is not None
|
||||||
|
assert user.role == Role.ADMIN.value
|
||||||
|
assert users.verify_password("super-secret-password-123", user.password_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_env_var_overrides_plain_env_var(
|
||||||
|
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""When both _FILE and bare env vars are set, _FILE wins."""
|
||||||
|
user_file, pw_file = _write_secrets(
|
||||||
|
tmp_path, username="file-user", password="file-password-12345"
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "env-user")
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "env-password-12345")
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME_FILE", str(user_file))
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD_FILE", str(pw_file))
|
||||||
|
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
|
with db.SessionLocal()() as session:
|
||||||
|
file_user = users.get_by_username(session, "file-user")
|
||||||
|
env_user = users.get_by_username(session, "env-user")
|
||||||
|
assert file_user is not None
|
||||||
|
assert env_user is None, "bare env var should be ignored when _FILE is set"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_falls_back_to_plain_env_var(
|
||||||
|
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME_FILE", raising=False)
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD_FILE", raising=False)
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "env-user")
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "env-password-12345")
|
||||||
|
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
|
with db.SessionLocal()() as session:
|
||||||
|
user = users.get_by_username(session, "env-user")
|
||||||
|
assert user is not None
|
||||||
|
assert user.role == Role.ADMIN.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_strips_trailing_whitespace_from_file(
|
||||||
|
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Secret files often have a trailing newline from `printf`/`echo`.
|
||||||
|
The bootstrap must strip it so bcrypt verify doesn't see whitespace."""
|
||||||
|
user_file = tmp_path / "admin_username"
|
||||||
|
pw_file = tmp_path / "admin_pw"
|
||||||
|
user_file.write_text("deploy-admin\n\n")
|
||||||
|
pw_file.write_text("super-secret-password-123\n")
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME_FILE", str(user_file))
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD_FILE", str(pw_file))
|
||||||
|
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
|
with db.SessionLocal()() as session:
|
||||||
|
user = users.get_by_username(session, "deploy-admin")
|
||||||
|
assert user is not None
|
||||||
|
# Should verify cleanly with no trailing whitespace.
|
||||||
|
assert users.verify_password("super-secret-password-123", user.password_hash)
|
||||||
|
assert not users.verify_password(
|
||||||
|
"super-secret-password-123\n", user.password_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_raises_when_file_path_missing(
|
||||||
|
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"CYCLONE_ADMIN_USERNAME_FILE", str(tmp_path / "does-not-exist")
|
||||||
|
)
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"CYCLONE_ADMIN_PASSWORD_FILE", str(tmp_path / "also-missing")
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="failed to read"):
|
||||||
|
bootstrap.run()
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""Dockerfile + compose-config smoke tests for SP23.
|
||||||
|
|
||||||
|
These tests do NOT require a running Docker daemon (the compose-up test
|
||||||
|
is gated on ``DOCKER_TESTS=1`` so it can be skipped on bare CI without
|
||||||
|
Docker). They validate that:
|
||||||
|
|
||||||
|
* ``docker-compose.yml`` at the repo root is syntactically valid and that
|
||||||
|
the shape we expect (services, secrets, volumes, networks) is present.
|
||||||
|
* Both Dockerfiles parse with ``docker build --check`` if Docker is on PATH.
|
||||||
|
* The named-volume mount paths match what ``cyclone.db`` + the BackupService
|
||||||
|
expect at runtime.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
COMPOSE_FILE = REPO_ROOT / "docker-compose.yml"
|
||||||
|
BACKEND_DOCKERFILE = REPO_ROOT / "backend" / "Dockerfile"
|
||||||
|
FRONTEND_DOCKERFILE = REPO_ROOT / "Dockerfile.frontend"
|
||||||
|
FRONTEND_NGINX_CONF = REPO_ROOT / "nginx.conf"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_docker() -> bool:
|
||||||
|
return shutil.which("docker") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_docker_compose() -> bool:
|
||||||
|
if shutil.which("docker") is None:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "compose", "version"],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
).returncode
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _volume_source(v) -> str:
|
||||||
|
"""Normalize a compose volume entry to the source name (str form or 'source' key)."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
# Long form: "named_volume:/container/path" — split on ':'.
|
||||||
|
return v.split(":", 1)[0]
|
||||||
|
if isinstance(v, dict):
|
||||||
|
return v.get("source", "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_file_exists():
|
||||||
|
assert COMPOSE_FILE.exists(), f"missing {COMPOSE_FILE}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_config_validates():
|
||||||
|
"""``docker compose config`` should exit 0 with no stderr."""
|
||||||
|
if not _has_docker_compose():
|
||||||
|
pytest.skip("docker compose not on PATH")
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"-f",
|
||||||
|
str(COMPOSE_FILE),
|
||||||
|
"config",
|
||||||
|
"--quiet",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"compose config failed: stderr={result.stderr!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_declares_required_services():
|
||||||
|
compose = yaml.safe_load(COMPOSE_FILE.read_text())
|
||||||
|
services = compose.get("services", {})
|
||||||
|
assert "backend" in services, "compose must declare a 'backend' service"
|
||||||
|
assert "frontend" in services, "compose must declare a 'frontend' service"
|
||||||
|
|
||||||
|
backend = services["backend"]
|
||||||
|
backend_volume_sources = {_volume_source(v) for v in backend.get("volumes", [])}
|
||||||
|
assert "cyclone_db" in backend_volume_sources, (
|
||||||
|
"backend must mount the cyclone_db volume"
|
||||||
|
)
|
||||||
|
assert backend.get("restart") == "unless-stopped", (
|
||||||
|
"backend must restart: unless-stopped so healthcheck failures recover"
|
||||||
|
)
|
||||||
|
assert "healthcheck" in backend, "backend must declare a healthcheck"
|
||||||
|
|
||||||
|
frontend = services["frontend"]
|
||||||
|
assert "8080:8080" in frontend.get("ports", []), (
|
||||||
|
"frontend must publish 8080:8080 for LAN access"
|
||||||
|
)
|
||||||
|
depends_on = frontend.get("depends_on") or {}
|
||||||
|
if isinstance(depends_on, dict):
|
||||||
|
backend_dep = depends_on.get("backend") or {}
|
||||||
|
assert backend_dep.get("condition") == "service_healthy", (
|
||||||
|
"frontend must wait for backend healthy before starting"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Short-form `depends_on: [backend]` is acceptable too — it implies
|
||||||
|
# service_started, not service_healthy. Flag a soft warning.
|
||||||
|
pytest.skip(
|
||||||
|
"frontend uses short-form depends_on; switch to long-form for service_healthy"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_declares_required_secrets_and_volumes():
|
||||||
|
compose = yaml.safe_load(COMPOSE_FILE.read_text())
|
||||||
|
secrets = compose.get("secrets", {})
|
||||||
|
for required in ("cyclone_db_key", "cyclone_admin_password"):
|
||||||
|
assert required in secrets, f"compose must declare secret {required!r}"
|
||||||
|
volumes = compose.get("volumes", {})
|
||||||
|
for required in (
|
||||||
|
"cyclone_db",
|
||||||
|
"cyclone_backups",
|
||||||
|
"cyclone_prodfiles",
|
||||||
|
"cyclone_sftp_staging",
|
||||||
|
"cyclone_logs",
|
||||||
|
):
|
||||||
|
assert required in volumes, f"compose must declare volume {required!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_backend_wires_backup_autostart():
|
||||||
|
"""The existing BackupService (SP17) needs CYCLONE_BACKUP_AUTOSTART=1
|
||||||
|
on container boot. The compose env block must include it."""
|
||||||
|
compose = yaml.safe_load(COMPOSE_FILE.read_text())
|
||||||
|
env = compose["services"]["backend"].get("environment", {})
|
||||||
|
assert str(env.get("CYCLONE_BACKUP_AUTOSTART")) == "1", (
|
||||||
|
"backend must autostart the backup scheduler (CYCLONE_BACKUP_AUTOSTART=1)"
|
||||||
|
)
|
||||||
|
assert "CYCLONE_BACKUP_INTERVAL_HOURS" in env
|
||||||
|
assert "CYCLONE_BACKUP_RETENTION_DAYS" in env
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not _has_docker(), reason="docker not on PATH; skipping Dockerfile parse check"
|
||||||
|
)
|
||||||
|
def test_backend_dockerfile_parses():
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"build",
|
||||||
|
"--check",
|
||||||
|
"-f",
|
||||||
|
str(BACKEND_DOCKERFILE),
|
||||||
|
str(REPO_ROOT / "backend"),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"backend Dockerfile failed to parse: stderr={result.stderr!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not _has_docker(), reason="docker not on PATH; skipping Dockerfile parse check"
|
||||||
|
)
|
||||||
|
def test_frontend_dockerfile_parses():
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"build",
|
||||||
|
"--check",
|
||||||
|
"-f",
|
||||||
|
str(FRONTEND_DOCKERFILE),
|
||||||
|
str(REPO_ROOT),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"frontend Dockerfile failed to parse: stderr={result.stderr!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not _has_docker_compose()
|
||||||
|
or not os.environ.get("DOCKER_TESTS"),
|
||||||
|
reason="DOCKER_TESTS=1 + docker compose required for live bring-up",
|
||||||
|
)
|
||||||
|
def test_compose_up_brings_up_healthy_stack():
|
||||||
|
"""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
|
||||||
|
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(
|
||||||
|
cmd_base + ["up", "-d", "--build"],
|
||||||
|
check=True,
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
import time
|
||||||
|
|
||||||
|
for _ in range(60):
|
||||||
|
ps = subprocess.run(
|
||||||
|
cmd_base + ["ps", "--format", "json"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
)
|
||||||
|
if "healthy" in ps.stdout:
|
||||||
|
return
|
||||||
|
time.sleep(2)
|
||||||
|
pytest.fail("compose stack did not become healthy within 120s")
|
||||||
|
finally:
|
||||||
|
subprocess.run(
|
||||||
|
cmd_base + ["down", "-v"],
|
||||||
|
check=False,
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
+79
-76
@@ -1,101 +1,104 @@
|
|||||||
# 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
|
name: cyclone
|
||||||
|
|
||||||
|
# Two-container production stack for Cyclone.
|
||||||
|
#
|
||||||
|
# backend FastAPI on python:3.11-slim-bookworm, internal port 8000.
|
||||||
|
# frontend nginx:1.27-alpine serving the built React SPA + reverse-
|
||||||
|
# proxying /api/* to the backend, host port 8080.
|
||||||
|
#
|
||||||
|
# Tag strategy:
|
||||||
|
# - Images are tagged semver from package.json / pyproject.toml.
|
||||||
|
# - Two aliases per image: `:latest` (set automatically by `docker
|
||||||
|
# compose build`) and `:stable` (manually promoted after a release
|
||||||
|
# has been running in prod for >=1 week).
|
||||||
|
# - Compose defaults to `:stable` so a fresh `docker compose build`
|
||||||
|
# never auto-rolls a new release.
|
||||||
|
# - Override with `TAG=0.0.9 docker compose up -d` to roll back.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
|
image: cyclone-backend:${TAG:-stable}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ./backend
|
||||||
dockerfile: backend/Dockerfile
|
|
||||||
image: cyclone-backend:local
|
|
||||||
container_name: cyclone-backend
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Bind on all interfaces inside the container (required — 127.0.0.1
|
# SQLite path lives on the named `cyclone_db` volume.
|
||||||
# would only be reachable from inside the container itself).
|
CYCLONE_DB_URL: "sqlite:////var/lib/cyclone/db/cyclone.db"
|
||||||
|
# Wire the existing BackupService (SP17) to autostart on container boot.
|
||||||
|
CYCLONE_BACKUP_AUTOSTART: "1"
|
||||||
|
CYCLONE_BACKUP_INTERVAL_HOURS: "24"
|
||||||
|
CYCLONE_BACKUP_RETENTION_DAYS: "14"
|
||||||
|
# Logging — JSON to stdout (collected by `docker logs`) and to the
|
||||||
|
# bind-mounted file (collected by host-side logrotate).
|
||||||
|
CYCLONE_LOG_LEVEL: "INFO"
|
||||||
|
CYCLONE_LOG_FILE: "/var/log/cyclone/cyclone.log"
|
||||||
|
CYCLONE_LOG_JSON: "1"
|
||||||
|
# Cookie signing key defaults to the SQLCipher key. Override in
|
||||||
|
# production if you want to rotate them independently.
|
||||||
|
CYCLONE_SECRET_KEY_FILE: "/run/secrets/cyclone_db_key"
|
||||||
|
CYCLONE_COOKIE_SECURE: "1"
|
||||||
|
# First-admin bootstrap — populated by scripts/cyclone-init.sh.
|
||||||
|
# The `_FILE` variants are the standard Docker-secret pattern:
|
||||||
|
# the env var points at the mounted secret file rather than
|
||||||
|
# embedding the secret in the compose file.
|
||||||
|
CYCLONE_ADMIN_USERNAME_FILE: "/run/secrets/cyclone_admin_username"
|
||||||
|
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"
|
CYCLONE_HOST: "0.0.0.0"
|
||||||
CYCLONE_PORT: "8000"
|
secrets:
|
||||||
CYCLONE_RELOAD: "0"
|
- cyclone_db_key
|
||||||
# Absolute path inside the container; the named volume mounts at /data.
|
- cyclone_admin_username
|
||||||
CYCLONE_DB_URL: "sqlite:////data/cyclone.db"
|
- cyclone_admin_password
|
||||||
# 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:
|
volumes:
|
||||||
- cyclone-data:/data
|
- cyclone_db:/var/lib/cyclone/db
|
||||||
# The healthcheck in the Dockerfile hits /api/health. The frontend
|
- cyclone_backups:/var/lib/cyclone/backups
|
||||||
# depends_on `service_healthy` so it won't accept traffic until the
|
- cyclone_prodfiles:/var/lib/cyclone/prodfiles
|
||||||
# backend is responsive.
|
- cyclone_sftp_staging:/var/lib/cyclone/sftp_staging
|
||||||
|
- cyclone_logs:/var/log/cyclone
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8000/api/health"]
|
test: ["CMD", "curl", "-fs", "http://localhost:8000/api/health"]
|
||||||
interval: 15s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 30s
|
||||||
# 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:
|
networks:
|
||||||
- cyclone-net
|
- cyclone_network
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
image: cyclone-frontend:${TAG:-stable}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: frontend/Dockerfile
|
dockerfile: Dockerfile.frontend
|
||||||
image: cyclone-frontend:local
|
|
||||||
container_name: cyclone-frontend
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# LAN-bind: only this port is published to the host. The operator
|
||||||
|
# is expected to firewall this to the LAN subnet (or rely on VPN
|
||||||
|
# for outside access). NO public TLS in v1.
|
||||||
|
- "8080:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
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:
|
networks:
|
||||||
- cyclone-net
|
- cyclone_network
|
||||||
|
|
||||||
networks:
|
secrets:
|
||||||
cyclone-net:
|
cyclone_db_key:
|
||||||
driver: bridge
|
file: /etc/cyclone/secrets/db.key
|
||||||
|
cyclone_admin_username:
|
||||||
|
file: /etc/cyclone/secrets/admin_username
|
||||||
|
cyclone_admin_password:
|
||||||
|
file: /etc/cyclone/secrets/admin_pw
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
cyclone-data:
|
cyclone_db:
|
||||||
name: cyclone-data
|
cyclone_backups:
|
||||||
|
cyclone_prodfiles:
|
||||||
|
cyclone_sftp_staging:
|
||||||
|
cyclone_logs:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
cyclone_network:
|
||||||
|
driver: bridge
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,28 @@
|
|||||||
# Cyclone Ubuntu Docker Deployment — Design
|
# Cyclone Ubuntu Docker Deployment — Design
|
||||||
|
|
||||||
**Date:** 2026-06-22
|
**Date:** 2026-06-22 (spec drafted); 2026-06-23 (reconciled with auth work that landed in main on 2026-06-23)
|
||||||
**Status:** Approved (pending user review of this doc)
|
**Status:** Approved (2026-06-23)
|
||||||
**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)
|
**Branch:** `sp23-ubuntu-docker-deployment`
|
||||||
|
**Aesthetic direction:** No new UI surface in v1 — the nginx + reverse-proxy posture is invisible to the operator; auth + dashboard styling is unchanged from what's already on main.
|
||||||
|
**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); the auth work that merged into `main` on 2026-06-23 (`a25504b`..`39ae988`, the `feat(auth):` series).
|
||||||
**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.
|
**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.1 Delta vs. `main` at 2026-06-23
|
||||||
|
|
||||||
|
The auth work that landed in main before SP23 began means sections 6 (auth modules + routes + matrix) and 7 (frontend login + AuthProvider + RoleGate) describe code that **already exists on main**. SP23's delta is therefore narrower than the original draft:
|
||||||
|
|
||||||
|
- **§6 (auth) — already on main.** The `cyclone.auth` package, `users` / `sessions` SQLAlchemy models (migrations `0013` + `0014`), bcrypt password hashing, the `PERMISSIONS` matrix + `matrix_gate` app-wide dependency, `Login.tsx` + `AuthProvider` + `RequireAuth` + `RoleGate`, the `cyclone admin` CLI, and `CYCLONE_AUTH_DISABLED=1` escape hatch are all merged. SP23 **reuses** them; it does not re-implement. §6 is kept in this spec as the contract that Docker + secrets + compose env vars must satisfy, not as work to do.
|
||||||
|
- **§7 (login UI) — already on main.** Reused.
|
||||||
|
- **§6.6 (new migration) — N/A.** No new SQL migration is needed for SP23; auth tables already exist.
|
||||||
|
- **What SP23 adds (the actual work):** §6.8 backend Dockerfile, §7.8 frontend Dockerfile + nginx.conf, §8.2 Docker secrets wiring, §9.4 image tag strategy + `:stable` aliases, §10 docker-compose.yml, §11 healthcheck + restart policy, §12.2 `tests/test_docker.py`, §9.5 bootstrap script, §9.6 RUNBOOK.md, post-deploy + smoke scripts.
|
||||||
|
- **Role name reconciliation:** the original draft used `admin / operator / viewer`. Main uses `admin / user / viewer`. All references to `operator` in this spec should be read as `user` against the live codebase.
|
||||||
|
- **Hashing algorithm reconciliation:** the original draft specified `argon2id`. Main uses `bcrypt`. The Docker secret posture is unaffected — both algorithms need only a key file or a salt, neither of which SP23 introduces.
|
||||||
|
- **Lockout timing:** original draft was 5 fails / 15 min. Auth on main is 5 fails / 5 min (`cyclone.auth.rate_limit`). SP23 inherits the main behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. Overview
|
## 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.
|
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.
|
||||||
@@ -123,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.
|
||||||
|
|
||||||
@@ -139,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;
|
||||||
@@ -439,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`.
|
||||||
@@ -522,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
|
||||||
@@ -694,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
|
||||||
|
|
||||||
@@ -781,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
|
||||||
|
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Long-lived connections for the live-tail NDJSON streams
|
||||||
|
# (snapshot + live events can hold open for minutes at a time).
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
|
||||||
|
# Claims files can be large; 50 MiB matches what FastAPI accepts.
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
# API + auth: proxy to backend over the compose-managed bridge network.
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://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;
|
||||||
|
# The backend sets the cookie with Path=/api/. Rewrite to / so
|
||||||
|
# the browser sends it on every subsequent request to the SPA
|
||||||
|
# (which never has a /api/ prefix on the URL).
|
||||||
|
proxy_cookie_path /api/ /;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback: serve index.html for any non-/api route so deep-links
|
||||||
|
# to /claims/123, /admin/users, etc. round-trip through reload.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Don't cache index.html — the SPA references hashed asset filenames,
|
||||||
|
# so index.html is the only file that needs to be revalidated.
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||||
|
expires off;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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": {
|
||||||
|
|||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# cyclone-init.sh — first-time host bootstrap for a production Cyclone deploy.
|
||||||
|
#
|
||||||
|
# Generates /etc/cyclone/secrets/{db.key,admin_username,admin_pw} with
|
||||||
|
# openssl rand -hex 32, sets ownership + permissions, and prints the
|
||||||
|
# admin password ONCE for the operator to capture.
|
||||||
|
#
|
||||||
|
# Idempotent: refuses to overwrite existing secrets unless --force.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash scripts/cyclone-init.sh # generate if missing
|
||||||
|
# bash scripts/cyclone-init.sh --force # overwrite (destroys old data!)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SECRETS_DIR="${CYCLONE_SECRETS_DIR:-/etc/cyclone/secrets}"
|
||||||
|
FORCE=0
|
||||||
|
[[ "${1:-}" == "--force" ]] && FORCE=1
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "ERROR: cyclone-init.sh must run as root (it writes to ${SECRETS_DIR})." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "${SECRETS_DIR}/db.key" && $FORCE -eq 0 ]]; then
|
||||||
|
echo "Secrets already exist at ${SECRETS_DIR}. Re-run with --force to overwrite." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${SECRETS_DIR}"
|
||||||
|
chmod 700 "${SECRETS_DIR}"
|
||||||
|
|
||||||
|
# 64-hex-char (256-bit) random key for SQLCipher + cookie signing.
|
||||||
|
openssl rand -hex 32 > "${SECRETS_DIR}/db.key"
|
||||||
|
|
||||||
|
# Admin username defaults to "admin" unless CYCLONE_ADMIN_USERNAME is set.
|
||||||
|
ADMIN_USER="${CYCLONE_ADMIN_USERNAME:-admin}"
|
||||||
|
printf '%s' "${ADMIN_USER}" > "${SECRETS_DIR}/admin_username"
|
||||||
|
|
||||||
|
# Memorable-but-random admin password — printed once.
|
||||||
|
ADMIN_PW="$(openssl rand -base64 18 | tr -d '/+=' | head -c 20)Aa1!"
|
||||||
|
printf '%s' "${ADMIN_PW}" > "${SECRETS_DIR}/admin_pw"
|
||||||
|
|
||||||
|
chmod 600 "${SECRETS_DIR}"/*
|
||||||
|
chown -R root:root "${SECRETS_DIR}"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
================================================================
|
||||||
|
Cyclone secrets generated at ${SECRETS_DIR}.
|
||||||
|
|
||||||
|
db.key SQLCipher key + cookie signing (64 hex chars)
|
||||||
|
admin_username ${ADMIN_USER}
|
||||||
|
admin_pw ${ADMIN_PW} <-- capture this NOW; it won't be shown again.
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
cd /opt/cyclone
|
||||||
|
docker compose pull # or: docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
bash scripts/post-deploy.sh # logrotate + healthcheck cron
|
||||||
|
bash scripts/smoke.sh # end-to-end smoke test
|
||||||
|
|
||||||
|
Then open http://<lan-ip>:8080/login and log in as '${ADMIN_USER}'.
|
||||||
|
Change the password from /admin/users immediately.
|
||||||
|
================================================================
|
||||||
|
EOF
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# post-deploy.sh — set up host-side logrotate and healthcheck cron for a
|
||||||
|
# production Cyclone deploy. Run once after `docker compose up -d`.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LOG_DIR="/var/log/cyclone"
|
||||||
|
HEALTHCHECK_URL="${CYCLONE_HEALTHCHECK_URL:-http://localhost:8080/api/health}"
|
||||||
|
HEALTHCHECK_EMAIL="${CYCLONE_HEALTHCHECK_EMAIL:-root}"
|
||||||
|
CRON_USER="${CYCLONE_CRON_USER:-root}"
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "ERROR: post-deploy.sh must run as root." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Logrotate — keeps 14 days of JSON logs compressed.
|
||||||
|
cat > /etc/logrotate.d/cyclone <<'LOGROTATE'
|
||||||
|
/var/log/cyclone/*.log {
|
||||||
|
daily
|
||||||
|
rotate 14
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
copytruncate
|
||||||
|
dateext
|
||||||
|
dateformat -%Y%m%d
|
||||||
|
}
|
||||||
|
LOGROTATE
|
||||||
|
|
||||||
|
mkdir -p "${LOG_DIR}"
|
||||||
|
chmod 755 "${LOG_DIR}"
|
||||||
|
|
||||||
|
# 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 health FAIL' ${HEALTHCHECK_EMAIL}"
|
||||||
|
TMP_CRON="$(mktemp)"
|
||||||
|
crontab -u "${CRON_USER}" -l 2>/dev/null > "${TMP_CRON}" || true
|
||||||
|
# Remove any existing cyclone healthcheck line, then re-add.
|
||||||
|
grep -v -F "Cyclone health" "${TMP_CRON}" > "${TMP_CRON}.new" || cp "${TMP_CRON}" "${TMP_CRON}.new"
|
||||||
|
echo "${CRON_LINE}" >> "${TMP_CRON}.new"
|
||||||
|
crontab -u "${CRON_USER}" "${TMP_CRON}.new"
|
||||||
|
rm -f "${TMP_CRON}" "${TMP_CRON}.new"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
post-deploy.sh complete:
|
||||||
|
- logrotate installed at /etc/logrotate.d/cyclone
|
||||||
|
- healthcheck cron for ${HEALTHCHECK_URL} installed for user '${CRON_USER}'
|
||||||
|
- logs rotate daily, 14 days retention
|
||||||
|
|
||||||
|
Verify with:
|
||||||
|
logrotate -d /etc/logrotate.d/cyclone
|
||||||
|
crontab -u ${CRON_USER} -l | grep -i cyclone
|
||||||
|
EOF
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# smoke.sh — end-to-end bring-up + login + parse + export smoke test.
|
||||||
|
#
|
||||||
|
# Assumes:
|
||||||
|
# - `docker compose up -d` is running.
|
||||||
|
# - /etc/cyclone/secrets/admin_pw exists (or CYCLONE_SECRETS_DIR overrides).
|
||||||
|
# - The sample 837 at docs/prodfiles/co_medicaid/sample_837p.txt is present.
|
||||||
|
#
|
||||||
|
# Override the URL with CYCLONE_SMOKE_URL, the sample path with
|
||||||
|
# CYCLONE_SMOKE_SAMPLE, the secrets dir with CYCLONE_SECRETS_DIR.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${CYCLONE_SMOKE_URL:-http://localhost:8080}"
|
||||||
|
SECRETS_DIR="${CYCLONE_SECRETS_DIR:-/etc/cyclone/secrets}"
|
||||||
|
ADMIN_PW="$(cat "${SECRETS_DIR}/admin_pw")"
|
||||||
|
COOKIE_JAR="$(mktemp)"
|
||||||
|
SAMPLE_837="${CYCLONE_SMOKE_SAMPLE:-docs/prodfiles/co_medicaid/sample_837p.txt}"
|
||||||
|
|
||||||
|
cleanup() { rm -f "${COOKIE_JAR}" /tmp/cyclone_smoke_export.zip; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "==> waiting for health..."
|
||||||
|
for i in {1..30}; do
|
||||||
|
if curl -fsS "${BASE_URL}/api/health" >/dev/null 2>&1; then break; fi
|
||||||
|
sleep 2
|
||||||
|
[[ $i -eq 30 ]] && { echo "FAIL: health never came up"; exit 1; }
|
||||||
|
done
|
||||||
|
echo " ok"
|
||||||
|
|
||||||
|
echo "==> logging in as admin..."
|
||||||
|
HTTP_CODE=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||||
|
-c "${COOKIE_JAR}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-X POST "${BASE_URL}/api/auth/login" \
|
||||||
|
-d "{\"username\":\"admin\",\"password\":\"${ADMIN_PW}\"}")
|
||||||
|
[[ "${HTTP_CODE}" == "200" ]] || { echo "FAIL: login returned ${HTTP_CODE}"; exit 1; }
|
||||||
|
echo " ok (cookie set)"
|
||||||
|
|
||||||
|
echo "==> /api/auth/me..."
|
||||||
|
ME=$(curl -fsS -b "${COOKIE_JAR}" "${BASE_URL}/api/auth/me")
|
||||||
|
echo " ${ME}"
|
||||||
|
|
||||||
|
if [[ -f "${SAMPLE_837}" ]]; then
|
||||||
|
echo "==> parsing sample 837 (${SAMPLE_837})..."
|
||||||
|
PARSE=$(curl -fsS -b "${COOKIE_JAR}" -X POST "${BASE_URL}/api/parse-837" \
|
||||||
|
-F "file=@${SAMPLE_837}")
|
||||||
|
echo " ${PARSE}" | head -c 200
|
||||||
|
echo
|
||||||
|
else
|
||||||
|
echo "==> skipping 837 parse (sample not found at ${SAMPLE_837})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> listing batches..."
|
||||||
|
BATCHES=$(curl -fsS -b "${COOKIE_JAR}" "${BASE_URL}/api/batches")
|
||||||
|
echo " ${BATCHES}" | head -c 200
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "smoke: OK"
|
||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user