7.0 KiB
type, status, tags, updated
| type | status | tags | updated | |||||||
|---|---|---|---|---|---|---|---|---|---|---|
| project | active |
|
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 innode:20-alpineDocker) - 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 indb/schema.sqlwith programmatic migrations indb/database.js - Port: 3001
- Deployment: Single container on Unraid, volume
/datafor the DB
Key architecture notes
- Violations carry a 90-day rolling window. Each violation stores a
prior_active_pointssnapshot 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_logtable records every write action. - Custom violation types stored in
violation_typestable; type_key prefixed withcustom_to avoid collisions.
Authentication (added 2026-05-27)
- Login screen gates the entire app. Every
/api/*route except/api/healthand/api/auth/loginrequires aBearertoken. - Bootstrap admin created/synced from
ADMIN_USERNAME/ADMIN_PASSWORDenv vars on every container start — its password is owned by Docker (cannot be changed in UI; UI changes overwritten on restart). DefaultADMIN_PASSWORD=changememust be overridden at deploy. - Password hashing: Node built-in
crypto.scryptSync(no new deps). Stored asscrypt$<saltHex>$<hashHex>. - Sessions: DB-backed in
sessionstable, 7-day TTL, token =crypto.randomBytes(32).toString('hex'). Sessions survive container restarts. - Roles:
adminanduser. 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 withresponseType: '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/healthand/api/auth/loginso those stay public; all routes below are protecteddb/database.js—usersandsessionstables addedclient/src/auth.js— axios interceptors + token helpersclient/src/components/LoginModal.jsx— login popupclient/src/components/UserManagementModal.jsx— admin Users panelclient/src/App.jsx— auth gate (authChecked/userstate), logout button, conditional Users buttonDockerfile—ENV ADMIN_USERNAME=adminandENV ADMIN_PASSWORD=changemedefaultsclient/src/components/ReadmeModal.jsx— in-app admin guide; updated with "Authentication & User Accounts" sectionREADME_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) — purecomputeStanding(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_scoresSQL view retired (dropped indb/database.js, removed fromschema.sql). SQL can't cleanly express oldest-first partial allocation, so all standing computation moved to JS to avoid SQL/JS divergence.server.jsrewired:/score,/dashboard,/expiration,getPriorActivePoints, PDF endpoint, andrecomputeSnapshotsAfterall go through the helper. Dashboard now fetches all non-negated violations once, buckets per employee, and computes in JS (fine at MPM scale)./expirationresponse 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}] }.recomputeSnapshotsAfterwindow 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.jsxre-rendered as a roll-off schedule with a "clock resets on any new violation" note.ViolationForm.jsxlabel 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_pointsalready 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)