Files
2026-06-05 00:49:20 -05:00

97 lines
7.0 KiB
Markdown

---
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$<saltHex>$<hashHex>`.
- **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 <token>` 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)