--- type: project status: active tags: - cpas - mpm-internal - sqlite - express - react - puppeteer - mpm updated: 2026-05-27 --- # CPAS Violation Tracker Internal MPM tool for tracking employee CPAS (Corrective Performance Action System) violations. Single Docker container, self-contained. Repo: `git.alwisp.com/jason/cpas` (also pushed to git.mpm.to as MPM internal). Local path: `F:\CODING\cpas\cpas`. ## Stack - **Backend:** Node.js + Express + `better-sqlite3` (better-sqlite3 won't compile on local Node v24 — builds fine in `node:20-alpine` Docker) - **Frontend:** React 18 + Vite, axios for API calls, dark theme inline styles (no Tailwind here) - **PDF:** Puppeteer-core driving Chromium in the container - **DB:** SQLite at `/data/cpas.db`, WAL mode, foreign keys ON. Schema lives in `db/schema.sql` with programmatic migrations in `db/database.js` - **Port:** 3001 - **Deployment:** Single container on Unraid, volume `/data` for the DB ## Key architecture notes - Violations carry a 90-day rolling window. Each violation stores a `prior_active_points` **snapshot** at submission so its PDF always shows the score *as it was on the incident date*. Snapshots auto-refresh on back-dated inserts and via a manual "Backfill Snapshots" button per employee. - Negate = soft delete (reversible). Hard delete is also supported. Both are audit-logged. - Append-only `audit_log` table records every write action. - Custom violation types stored in `violation_types` table; type_key prefixed with `custom_` to avoid collisions. ## Authentication (added 2026-05-27) - **Login screen** gates the entire app. Every `/api/*` route except `/api/health` and `/api/auth/login` requires a `Bearer` token. - **Bootstrap admin** created/synced from `ADMIN_USERNAME` / `ADMIN_PASSWORD` env vars on every container start — its password is owned by Docker (cannot be changed in UI; UI changes overwritten on restart). Default `ADMIN_PASSWORD=changeme` must be overridden at deploy. - **Password hashing:** Node built-in `crypto.scryptSync` (no new deps). Stored as `scrypt$$`. - **Sessions:** DB-backed in `sessions` table, 7-day TTL, token = `crypto.randomBytes(32).toString('hex')`. Sessions survive container restarts. - **Roles:** `admin` and `user`. Admin sees a **Users** button in the nav to add/delete users and reset passwords. - **Frontend:** axios request interceptor injects `Authorization: Bearer ` from localStorage on every call; response interceptor clears token + falls back to login on 401. Covers all components including blob PDF downloads (which use axios with `responseType: 'blob'`, no direct anchor links). ## Files of note - `auth.js` — auth module (hashing, sessions, user CRUD, middleware, `bootstrapAdmin()`) - `server.js` — `app.use('/api', auth.requireAuth)` placed after `/api/health` and `/api/auth/login` so those stay public; all routes below are protected - `db/database.js` — `users` and `sessions` tables added - `client/src/auth.js` — axios interceptors + token helpers - `client/src/components/LoginModal.jsx` — login popup - `client/src/components/UserManagementModal.jsx` — admin Users panel - `client/src/App.jsx` — auth gate (`authChecked`/`user` state), logout button, conditional Users button - `Dockerfile` — `ENV ADMIN_USERNAME=admin` and `ENV ADMIN_PASSWORD=changeme` defaults - `client/src/components/ReadmeModal.jsx` — in-app admin guide; updated with "Authentication & User Accounts" section - `README_UNRAID_INSTALL.md` — adds steps 5.5 Variables 3+4 (admin env vars), login note in Verify, troubleshooting rows for forgotten admin password ## Current status Authentication shipped (2026-05-27). Backend syntax-checks pass; client builds clean. Could not verify locally because better-sqlite3 fails to compile on Node v24 — verification requires building/running the Docker container. ## Open considerations - No expanded role model yet (only admin/user). Future: supervisor-scoped or read-only roles. - No password-change UI for non-admin users (they ask an admin to reset). Could add. - Sessions table has no periodic cleanup of expired tokens; expired tokens are pruned lazily on use. ## Roll-off model fix (2026-05-27) Earlier "90-day rolling window per violation" wording above is **superseded**. The handbook actually specifies a *clean-cycle* roll-off: points only retire after 90 consecutive days with NO new violation, and any new non-negated violation resets the clock for the whole employee. Each completed clean cycle removes 5 points oldest-first; each successive 5-point roll-off needs another fresh 90 clean days. The old per-violation expiry was rolling whole violations off independently 90 days from their own incident date — so a new violation didn't reset the countdown on earlier ones. Bug surfaced by Jason; fixed same session. ### Implementation - `lib/rolloff.js` (new) — pure `computeStanding(violations, asOf)` is the **single source of truth**. Returns `{ activePoints, totalPoints, rolledOffPoints, cycles, violationCount, lastViolationDate, nextRolloffDate, perViolation, schedule }`. Verified with scenario tests (single violation, two-violation reset case, multi-cycle decay). - `active_cpas_scores` SQL view **retired** (dropped in `db/database.js`, removed from `schema.sql`). SQL can't cleanly express oldest-first partial allocation, so all standing computation moved to JS to avoid SQL/JS divergence. - `server.js` rewired: `/score`, `/dashboard`, `/expiration`, `getPriorActivePoints`, PDF endpoint, and `recomputeSnapshotsAfter` all go through the helper. Dashboard now fetches all non-negated violations once, buckets per employee, and computes in JS (fine at MPM scale). - `/expiration` response shape changed from a list of per-violation rows to `{ active_points, last_violation_date, next_rolloff_date, schedule: [{date, points_off, points_after, days_remaining}] }`. - `recomputeSnapshotsAfter` window widened: under the new model an earlier backdated insert shifts every later violation's snapshot (not just within 90 days), so it now scans all later violations. - Frontend: `ExpirationTimeline.jsx` re-rendered as a roll-off schedule with a "clock resets on any new violation" note. `ViolationForm.jsx` label changed from "violations in last 90 days" → "active violations". ### Migration / backfill posture - **Automatic on restart:** every live read (dashboard, score, expiration, PDF current standing, prior points for NEW inserts). - **Needs manual backfill:** `prior_active_points` already stored on pre-fix violation rows — they were frozen under the old rule. Existing "Backfill Snapshots" button (`POST /api/employees/:id/recompute-snapshots`) rewrites them per employee. Skip unless historical PDFs matter. ### Files touched - `lib/rolloff.js` (new) - `db/database.js` (drop view) - `db/schema.sql` (drop view) - `server.js` (helper require, `loadViolations`/`standingFor`, all 5 endpoint rewires) - `client/src/components/ExpirationTimeline.jsx` (full rewrite of render) - `client/src/components/ViolationForm.jsx` (label tweak)