Files
echo/.references/references-only/reference vault/memory/projects/cpas.md
T
2026-06-05 00:49:20 -05:00

7.0 KiB

type, status, tags, updated
type status tags updated
project active
cpas
mpm-internal
sqlite
express
react
puppeteer
mpm
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.jsapp.use('/api', auth.requireAuth) placed after /api/health and /api/auth/login so those stay public; all routes below are protected
  • db/database.jsusers 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
  • DockerfileENV 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)