+ Only non-scoring fields can be amended. Point values, violation type, and incident date
+ are immutable — delete and re-submit if those need to change.
+
+ ✓ Merge complete — {mergeResult.violations_reassigned} violation{mergeResult.violations_reassigned !== 1 ? 's' : ''} reassigned
+ to {employee.name}. The duplicate record has been removed.
+
+ ) : (
+ <>
+
+ ⚠ This will reassign all violations from the selected employee into{' '}
+ {employee.name}, then permanently delete the duplicate record.
+ This cannot be undone.
+
+ );
+}
diff --git a/.claude/worktrees/musing-bell/client/src/components/ReadmeModal.jsx b/.claude/worktrees/musing-bell/client/src/components/ReadmeModal.jsx
new file mode 100644
index 0000000..fee0a27
--- /dev/null
+++ b/.claude/worktrees/musing-bell/client/src/components/ReadmeModal.jsx
@@ -0,0 +1,340 @@
+import React, { useEffect, useRef } from 'react';
+
+// Minimal Markdown to HTML renderer (headings, bold, inline-code, tables, hr, ul, ol, paragraphs)
+function mdToHtml(md) {
+ const lines = md.split('\n');
+ const out = [];
+ let i = 0, inUl = false, inOl = false, inTable = false;
+
+ const close = () => {
+ if (inUl) { out.push(''); inUl = false; }
+ if (inOl) { out.push(''); inOl = false; }
+ if (inTable) { out.push(''); inTable = false; }
+ };
+ const inline = s =>
+ s.replace(/&/g,'&').replace(//g,'>')
+ .replace(/\*\*(.+?)\*\*/g,'$1')
+ .replace(/`([^`]+)`/g,'$1');
+
+ while (i < lines.length) {
+ const line = lines[i];
+ if (line.startsWith('```')) { close(); i++; while (i < lines.length && !lines[i].startsWith('```')) i++; i++; continue; }
+ if (/^---+$/.test(line.trim())) { close(); out.push(''); i++; continue; }
+ const hm = line.match(/^(#{1,4})\s+(.+)/);
+ if (hm) { close(); const lv=hm[1].length, id=hm[2].toLowerCase().replace(/[^a-z0-9]+/g,'-'); out.push(`${inline(hm[2])}`); i++; continue; }
+ if (line.trim().startsWith('|')) {
+ const cells = line.trim().replace(/^\|||\|$/g,'').split('|').map(c=>c.trim());
+ if (!inTable) { close(); inTable=true; out.push('
'); cells.forEach(c=>out.push(`
${inline(c)}
`)); out.push('
'); i++; if (i < lines.length && /^[\|\s\:\-]+$/.test(lines[i])) i++; continue; }
+ else { out.push('
'); cells.forEach(c=>out.push(`
${inline(c)}
`)); out.push('
'); i++; continue; }
+ }
+ const ul = line.match(/^[-*]\s+(.*)/);
+ if (ul) { if (inTable) close(); if (!inUl) { if (inOl){out.push('');inOl=false;} out.push('
');inUl=true; } out.push(`
${inline(ul[1])}
`); i++; continue; }
+ const ol = line.match(/^\d+\.\s+(.*)/);
+ if (ol) { if (inTable) close(); if (!inOl) { if (inUl){out.push('
`); i++;
+ }
+ close();
+ return out.join('\n');
+}
+
+function buildToc(md) {
+ return md.split('\n').reduce((acc, line) => {
+ const m = line.match(/^(#{1,2})\s+(.+)/);
+ if (m) acc.push({ level: m[1].length, text: m[2], id: m[2].toLowerCase().replace(/[^a-z0-9]+/g,'-') });
+ return acc;
+ }, []);
+}
+
+// ——— Styles ——————————————————————————————————————————————————————————————————
+const S = {
+ overlay: { position:'fixed', inset:0, background:'rgba(0,0,0,0.75)', zIndex:2000, display:'flex', alignItems:'flex-start', justifyContent:'flex-end' },
+ panel: { background:'#111217', color:'#f8f9fa', width:'760px', maxWidth:'95vw', height:'100vh', overflowY:'auto', boxShadow:'-4px 0 32px rgba(0,0,0,0.85)', display:'flex', flexDirection:'column' },
+ header: { background:'linear-gradient(135deg,#000000,#151622)', color:'white', padding:'22px 28px', position:'sticky', top:0, zIndex:10, borderBottom:'1px solid #222', display:'flex', alignItems:'center', justifyContent:'space-between' },
+ closeBtn:{ background:'none', border:'none', color:'white', fontSize:'22px', cursor:'pointer', lineHeight:1 },
+ toc: { background:'#0d1117', borderBottom:'1px solid #1e1f2e', padding:'10px 32px', display:'flex', flexWrap:'wrap', gap:'4px 18px', fontSize:'11px' },
+ body: { padding:'28px 32px', flex:1, fontSize:'13px', lineHeight:'1.75' },
+ footer: { padding:'14px 32px', borderTop:'1px solid #1e1f2e', fontSize:'11px', color:'#555770', textAlign:'center' },
+};
+
+const CSS = `
+.adm h1 { font-size:21px; font-weight:800; color:#f8f9fa; margin:28px 0 10px; border-bottom:1px solid #2a2b3a; padding-bottom:8px }
+.adm h2 { font-size:16px; font-weight:700; color:#d4af37; margin:28px 0 6px; letter-spacing:.2px }
+.adm h3 { font-size:12px; font-weight:700; color:#90caf9; margin:18px 0 4px; text-transform:uppercase; letter-spacing:.5px }
+.adm h4 { font-size:13px; font-weight:600; color:#b0b8d0; margin:14px 0 4px }
+.adm p { color:#c8ccd8; margin:5px 0 10px }
+.adm hr { border:none; border-top:1px solid #2a2b3a; margin:22px 0 }
+.adm strong { color:#f8f9fa }
+.adm code { background:#0d1117; color:#79c0ff; border:1px solid #2a2b3a; border-radius:4px; padding:1px 6px; font-family:'Consolas','Fira Code',monospace; font-size:12px }
+.adm ul { padding-left:20px; margin:5px 0 10px; color:#c8ccd8 }
+.adm ol { padding-left:20px; margin:5px 0 10px; color:#c8ccd8 }
+.adm li { margin:4px 0 }
+.adm table { width:100%; border-collapse:collapse; font-size:12px; background:#181924; border-radius:6px; overflow:hidden; border:1px solid #2a2b3a; margin:10px 0 16px }
+.adm th { background:#050608; padding:8px 12px; text-align:left; color:#f8f9fa; font-weight:600; font-size:11px; text-transform:uppercase; border-bottom:1px solid #2a2b3a }
+.adm td { padding:8px 12px; border-bottom:1px solid #202231; color:#c8ccd8 }
+.adm tr:last-child td { border-bottom:none }
+.adm tr:hover td { background:#1e1f2e }
+`;
+
+// ——— Admin guide content (no install / Docker content) ————————————————————
+const GUIDE_MD = `# CPAS Tracker — Admin Guide
+
+Internal tool for CPAS violation documentation, workforce standing management, and audit compliance. All data is stored locally in the Docker container volume — there is no external dependency.
+
+---
+
+## How Scoring Works
+
+Every violation carries a **point value** set at the time of submission. Points count toward an employee's score only within a **rolling 90-day window** — once a violation is older than 90 days it automatically drops off and the score recalculates.
+
+Negated (voided) violations are excluded from scoring immediately. Hard-deleted violations are removed from the record entirely.
+
+## Tier Reference
+
+| Points | Tier | Label |
+|--------|------|-------|
+| 0–4 | 0–1 | Elite Standing |
+| 5–9 | 1 | Realignment |
+| 10–14 | 2 | Administrative Lockdown |
+| 15–19 | 3 | Verification |
+| 20–24 | 4 | Risk Mitigation |
+| 25–29 | 5 | Final Decision |
+| 30+ | 6 | Separation |
+
+The **at-risk badge** on the dashboard flags anyone within 2 points of the next tier threshold so supervisors can act before escalation occurs.
+
+---
+
+## Feature Map
+
+### Dashboard
+
+The main view. Employees are sorted by active CPAS points, highest first.
+
+- **Stat cards** — live counts: total employees, zero-point (elite), with active points, at-risk, highest score
+- **Search / filter** — by name, department, or supervisor; narrows the table in real time
+- **At-risk badge** — gold flag on rows where the employee is within 2 pts of the next tier
+- **Audit Log button** — opens the filterable, paginated write-action log (top right of the dashboard toolbar)
+- **Click any name** — opens that employee's full profile modal
+
+---
+
+### Logging a Violation
+
+Use the **+ New Violation** tab.
+
+1. Select an existing employee from the dropdown, or type a new name to create a record on-the-fly.
+2. The **employee intelligence panel** loads their current tier badge and 90-day violation count before you commit.
+3. Choose a violation type. The dropdown is grouped by category and shows prior 90-day counts inline for each type.
+4. If the employee has a prior violation of the same type, the **recidivist auto-escalation** rule triggers — the points slider jumps to the maximum allowed for that violation type.
+5. The **tier crossing warning** previews what tier the submission would land the employee in. Review before submitting.
+6. Adjust points using the slider if discretionary reduction is warranted (within the violation's allowed min/max range).
+7. **Employee Acknowledgment** (optional): if the employee is present and acknowledges receipt, enter their printed name and the acknowledgment date. This replaces the blank signature line on the PDF with a recorded acknowledgment and an "Acknowledged" badge. Leave blank if the employee is not present or declines.
+8. Submit. A **PDF download link** appears immediately — download it for the employee's file.
+9. **Toast notifications** confirm success or surface errors at the top right of the screen. Toasts auto-dismiss after a few seconds.
+
+---
+
+### Employee Profile Modal
+
+Click any name on the dashboard to open their profile.
+
+#### Overview section
+Shows current tier badge, active points, and 90-day violation count.
+
+#### Notes & Flags
+Free-text field for HR context (e.g. "On PIP", "Union member", "Pending investigation", "FMLA"). Quick-add tag buttons pre-fill common statuses. Notes are visible to anyone who opens the profile but **do not affect CPAS scoring**. Edit inline; saves on blur.
+
+#### Point Expiration Timeline
+Visible when the employee has active points. Shows each active violation as a progress bar indicating how far through its 90-day window it is, days remaining until roll-off, and a **tier-drop indicator** for violations whose expiration would move the employee down a tier.
+
+#### Violation History
+Full record of all submissions — active, negated, and resolved.
+
+- **Amend** — edit non-scoring fields (location, details, witness, submitted-by, incident time, acknowledged-by, acknowledged-date) on any active violation. Every change is logged as a field-level diff (old → new) with timestamp. Points, type, and incident date are immutable.
+- **Negate** — soft-delete a violation with a resolution type and notes. The record is preserved in history; the points are immediately removed from the score. Fully reversible via **Restore**.
+- **Hard delete** — permanent removal. Use only for genuine data entry errors.
+- **PDF** — download the formal violation document for any historical record. If the violation has an employee acknowledgment on record, the PDF shows the filled-in name and date instead of blank signature lines.
+
+All actions trigger **toast notifications** confirming success or surfacing errors.
+
+#### Edit Employee
+Update name, department, or supervisor. Changes are logged to the audit trail.
+
+#### Merge Duplicate
+If the same employee exists under two names, use Merge to reassign all violations from the duplicate to the canonical record. The duplicate is then deleted. This cannot be undone.
+
+---
+
+### Audit Log
+
+Accessible from the dashboard toolbar (🔍 button). Append-only log of every write action in the system.
+
+- Filter by entity type: **employee** or **violation**
+- Filter by action: created, edited, merged, negated, restored, amended, deleted, notes updated
+- Paginated with load-more; most recent entries first
+
+The audit log is the authoritative record for compliance review. Nothing in it can be edited or deleted through the UI.
+
+---
+
+### Violation Amendment
+
+Amendments allow corrections to a violation's non-scoring fields without deleting and re-submitting, which would disrupt the audit trail and the prior-points snapshot.
+
+**Amendable fields:** incident time, location, details, submitted-by, witness name, acknowledged-by, acknowledged-date.
+
+**Immutable fields:** violation type, incident date, point value.
+
+Each amendment stores a before/after diff for every changed field. Amendment history is accessible from the violation card in the employee's history.
+
+---
+
+### Toast Notifications
+
+All user actions across the application produce **toast notifications** — small slide-in messages at the top right of the screen.
+
+- **Success** (green) — violation submitted, PDF downloaded, employee updated, etc.
+- **Error** (red) — API failures, validation errors, PDF generation issues
+- **Warning** (gold) — missing required fields, policy alerts
+- **Info** (blue) — general informational messages
+
+Toasts auto-dismiss after a few seconds (errors persist longer). Each toast has a progress bar countdown and a manual dismiss button. Up to 5 toasts can stack simultaneously.
+
+---
+
+## Immutability Rules — Quick Reference
+
+| Action | Allowed? | Notes |
+|--------|----------|-------|
+| Edit violation type | No | Immutable after submission |
+| Edit incident date | No | Immutable after submission |
+| Edit point value | No | Immutable after submission |
+| Edit location / details / witness | Yes | Via Amend |
+| Edit acknowledged-by / acknowledged-date | Yes | Via Amend |
+| Negate (void) a violation | Yes | Soft delete; reversible |
+| Hard delete a violation | Yes | Permanent; use sparingly |
+| Edit employee name / dept / supervisor | Yes | Logged to audit trail |
+| Merge duplicate employees | Yes | Irreversible |
+| Add / edit employee notes | Yes | Does not affect score |
+
+---
+
+## Roadmap
+
+### Shipped
+
+- Container scaffold, violation form, employee intelligence
+- Recidivist auto-escalation, tier crossing warning
+- PDF generation with prior-points snapshot
+- Company dashboard, stat cards, at-risk badges
+- Employee profile modal — full history, negate/restore, hard delete
+- Employee edit and duplicate merge
+- Violation amendment with field-level diff log
+- Audit log — filterable, paginated, append-only
+- Employee notes and flags with quick-add HR tags
+- Point expiration timeline with tier-drop projections
+- In-app admin guide (this panel)
+- Acknowledgment signature field — employee name + date on form and PDF
+- Toast notification system — global feedback for all user actions
+
+---
+
+### Near-term
+
+These are well-scoped additions that fit the current architecture without major changes.
+
+- **CSV export** — one endpoint returning violations or dashboard data as a downloadable CSV for payroll or external reporting.
+- **Supervisor-scoped view** — filter the dashboard to a single supervisor's team via URL param; useful in multi-supervisor environments without requiring full auth.
+
+---
+
+### Planned
+
+Larger features that require more design work or infrastructure.
+
+- **Violation trends chart** — line/bar chart of violations over time, filterable by department or supervisor. Useful for identifying systemic patterns vs. isolated incidents. Recharts is already available in the frontend bundle.
+- **Department heat map** — grid showing violation density and average CPAS score per department. Helps identify team-level risk early.
+- **Draft / pending violations** — save a violation as a draft before it's officially logged. Useful when incidents need supervisor review or HR sign-off before they count toward the score.
+- **At-risk threshold configuration** — make the 2-point at-risk warning threshold configurable per deployment rather than hardcoded.
+
+---
+
+### Future Considerations
+
+These require meaningful infrastructure additions and should be evaluated against actual operational need before committing.
+
+- **Multi-user auth** — role-based login (admin, supervisor, read-only). Currently the app assumes a trusted internal network with no authentication layer.
+- **Tier escalation alerts** — email or in-app notification when an employee crosses into Tier 2+, automatically routed to their supervisor.
+- **Scheduled digest** — weekly email summary to supervisors showing their employees' current standings and any approaching thresholds.
+- **Automated DB backup** — scheduled snapshot of the database to a mounted backup volume or remote destination.
+- **Bulk CSV import** — migrate historical violation records from paper logs or a prior system.
+- **Dark/light theme toggle** — UI is currently dark-only.
+`;
+
+// ——— Component ——————————————————————————————————————————————————————————————
+export default function ReadmeModal({ onClose }) {
+ const bodyRef = useRef(null);
+ const html = mdToHtml(GUIDE_MD);
+ const toc = buildToc(GUIDE_MD);
+
+ useEffect(() => {
+ const h = e => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', h);
+ return () => window.removeEventListener('keydown', h);
+ }, [onClose]);
+
+ const scrollTo = id => {
+ const el = bodyRef.current?.querySelector(`#${id}`);
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ };
+
+ return (
+
{ if (e.target === e.currentTarget) onClose(); }}>
+
+
+
e.stopPropagation()}>
+
+ {/* Header */}
+
+
+
+ 📋 CPAS Tracker — Admin Guide
+
+
+ Feature map · workflows · roadmap · Esc or click outside to close
+
+
+
+
+
+ {/* TOC strip */}
+
+ {toc.map(h => (
+
+ ))}
+
+
+ {/* Body */}
+
+
+ {/* Footer */}
+
+ CPAS Violation Tracker · internal admin use only
+
+
+
+ );
+}
diff --git a/.claude/worktrees/musing-bell/client/src/components/TierWarning.jsx b/.claude/worktrees/musing-bell/client/src/components/TierWarning.jsx
new file mode 100644
index 0000000..7721260
--- /dev/null
+++ b/.claude/worktrees/musing-bell/client/src/components/TierWarning.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { getTier, getNextTier } from './CpasBadge';
+
+/**
+ * Shows a warning banner if adding `addingPoints` to `currentPoints`
+ * would cross into a new CPAS tier.
+ */
+export default function TierWarning({ currentPoints, addingPoints }) {
+ if (!currentPoints && currentPoints !== 0) return null;
+
+ const current = getTier(currentPoints);
+ const projected = getTier(currentPoints + addingPoints);
+
+ if (current.label === projected.label) return null;
+
+ const tierUp = getNextTier(currentPoints);
+
+ return (
+
+ ⚠ Tier Escalation Warning
+ Adding {addingPoints} point{addingPoints !== 1 ? 's' : ''} will move this employee
+ from {current.label} to {projected.label}.
+ {tierUp && (
+ Tier threshold crossed at {tierUp.min} points.
+ )}
+
+
+ DEMO ENVIRONMENT — Simulated data for stakeholder preview only — Not connected to live database
+
+
+
+
+
+
+
+
+
+
+
+
+
Corrective Performance Action System
+
Employee Compliance Dashboard
+
Real-time visibility into workforce disciplinary standing. Track violations, monitor tier escalations, and generate signed documentation — all in one place.
+
+
47
Total Employees
+
+
23
Active Violations (90d)
+
+
3
At-Risk (Tier 3+)
+
+
91%
In Good Standing
+
+
+
+
+
+
New This Week
+
6
+
+2 vs prior week
+
+
+
+
Tier 3+ Employees
+
3
+
Requires attention
+
+
+
+
PDFs Generated
+
18
+
This month
+
+
+
+
Expiring (30d)
+
9
+
Points rolling off
+
+
+
+
+
+
+
+
+ Employee Roster
+ 47 total
+
+
+
Employee
Dept
Standing
+
+
Marcus T.
Operations
22 pts — Tier 4
+
Janelle R.
Production
17 pts — Tier 3
+
Devon H.
Operations
15 pts — Tier 3
+
Priya S.
Impl & Support
12 pts — Tier 2
+
Carlos M.
Production
7 pts — Tier 1
+
Aisha W.
Administrative
5 pts — Tier 1
+
Tom B.
Design & Content
2 pts — Elite
+
Sandra K.
Business Dev
0 pts — Elite
+
+
+
+
+
+
+
+ Recent Violations
+ Last 7 days
+
+
+
+
+
+
Marcus T. ☆ REPEAT
+
Unauthorized Absence — Operations
+
Mar 6·D. Williams
+
+
+5
+
+
+
+
+
Janelle R.
+
Insubordination — Production
+
Mar 5·K. Thompson
+
+
+4
+
+
+
+
+
Devon H.
+
Tardiness (3×) — Operations
+
Mar 4·D. Williams
+
+
+3
+
+
+
+
+
Carlos M.
+
Cell Phone Policy — Production
+
Mar 3·K. Thompson
+
+
+2
+
+
+
+
+
Priya S.
+
Dress Code Violation — Impl & Support
+
Mar 2·M. Johnson
+
+
+1
+
+
+
+
+
Aisha W.
+
Late Return from Break — Administrative
+
Mar 1·S. Martinez
+
+
+1
+
+
+
+
+
+
+
+
+
+ Violations by Department
+ 90-day window
+
+
+
Operations
8
+
Production
6
+
Impl & Support
4
+
Administrative
2
+
Business Dev
1
+
Design & Content
1
+
Executive
0
+
+
+
+
+
+
+ Audit Log
+ System activity
+
+
+
03/06 2:14p
Violation #41 created — Marcus T.
+5 pts
+
03/06 2:15p
PDF generated for Violation #41
—
+
03/05 9:40a
Violation #40 created — Janelle R.
+4 pts
+
03/04 11:20a
Employee Devon H. record updated
—
+
03/04 8:55a
Violation #39 created — Devon H.
+3 pts
+
03/03 3:30p
Violation #38 amended — Carlos M.
−1 pt
+
03/02 1:05p
Duplicate record merged — R. Johnson
—
+
+
+
+
+
+
+
+
CPAS Tier Scale
+
+
+
0–4
Elite Standing
+
5–9
Tier 1 Realignment
+
10–14
Tier 2 Admin Lockdown
+
15–19
Tier 3 Verification
+
20–24
Tier 4 Risk Mitigation
+
25–29
Tier 5 Final Decision
+
30+
Tier 6 Separation
+
+
+
+
+
System Capabilities
+
+
⚡
Repeat Offense Detection
Automatically flags prior violations for the same type and escalates point recommendations per recidivist policy.
+
📄
One-Click PDF Generation
Generates signed, professional violation documents instantly — with or without employee acknowledgment signatures.
+
📀
Duplicate Record Merge
Consolidate duplicate employee records while preserving full violation history under the canonical profile.
+
🕊
90-Day Rolling Window
Points automatically expire after 90 days. Active standing reflects only the current compliance window.
+
🏷️
Tier Escalation Warnings
Real-time alerts when a new violation would push an employee across a tier boundary before you submit.
+
🗂️
Full Audit Trail
Every create, amendment, merge, and PDF generation is logged with timestamp and operator attribution.
+
+
+
+
+
+
+
+
+
+ ⚡ DEMO VIEW — Form fields shown with sample data. Submission is disabled in demo mode.
+
+
+
+
+
+
Employee Information
+
+
Quick-Select Existing Employee:
+
+ Marcus Thompson — Operations▼
+
+
+ Current Standing:
+ 22 pts — Tier 4 · Risk Mitigation
+ 4 violations in last 90 days
+
+
+
+
Employee Name:
Marcus Thompson
+
Department:
Operations▼
+
Supervisor Name:
D. Williams
+
Witness Name (Officer):
Officer Name
+
+
+
+
+
Violation Details
+
+
+
Violation Type:
+
+ Unauthorized Absence ☆ 2x in 90 days▼
+
+
+ Unauthorized Absence☆ Repeat — 2x prior
+ Absence from scheduled work without prior approval or acceptable documentation.
+ Chapter 4, Section 4.1 — Attendance & Punctuality
+
+
+ Repeat offense detected. Point slider set to maximum (5 pts) per recidivist policy. Adjust if needed.
+
+
+
Incident Date:
2026-03-06
+
+
+ ⚡ Tier escalation warning: Adding 5 pts will bring Marcus to 27 pts (Tier 5 — Final Decision). This is one tier below Separation. Review carefully.
+
+
+
CPAS Point Assessment
+
Unauthorized Absence: 3–5 Points
+
+
5 Points
+
Adjust to reflect severity and context
+
+
+
+
+
Employee Acknowledgment
+
If the employee is present and acknowledges receipt of this violation, enter their name and the date below.
+ Employee Notice: CPAS points remain active for a rolling 90-day period from the date of each incident.
+ Accumulation of points may result in tier escalation and associated consequences as outlined in the Employee Handbook.
+ The employee may submit a written response within 5 business days of receiving this document.
+
+ By signing below, the employee acknowledges receipt of this violation record.
+ Acknowledgement does not imply agreement with the violation as documented.
+
+
+
+ ${hasAck
+ ? `
${ackName}
`
+ : ''}
+
Employee Signature
+ ${hasAck && ackDate
+ ? `
${ackDate}
`
+ : ''}
+
Date
+
+
+
+
Supervisor / Documenting Officer Signature
+
+
Date
+
+
+
+
+
+
+
+
+`;
+}
+
+module.exports = buildHtml;
diff --git a/.claude/worktrees/musing-bell/server.js b/.claude/worktrees/musing-bell/server.js
new file mode 100644
index 0000000..e75c7f3
--- /dev/null
+++ b/.claude/worktrees/musing-bell/server.js
@@ -0,0 +1,546 @@
+const express = require('express');
+const cors = require('cors');
+const path = require('path');
+const db = require('./db/database');
+const generatePdf = require('./pdf/generator');
+
+const app = express();
+const PORT = process.env.PORT || 3001;
+
+app.use(cors());
+app.use(express.json());
+app.use(express.static(path.join(__dirname, 'client', 'dist')));
+
+// ── Demo static route ─────────────────────────────────────────────────────────
+// Serves the standalone stakeholder demo page at /demo/index.html
+// Must be registered before the SPA catch-all below.
+app.use('/demo', express.static(path.join(__dirname, 'demo')));
+
+// ── Audit helper ─────────────────────────────────────────────────────────────
+function audit(action, entityType, entityId, performedBy, details) {
+ try {
+ db.prepare(`
+ INSERT INTO audit_log (action, entity_type, entity_id, performed_by, details)
+ VALUES (?, ?, ?, ?, ?)
+ `).run(action, entityType, entityId || null, performedBy || null,
+ typeof details === 'object' ? JSON.stringify(details) : (details || null));
+ } catch (e) {
+ console.error('[AUDIT]', e.message);
+ }
+}
+
+// ── Version info (written by Dockerfile at build time) ───────────────────────
+// Falls back to { sha: 'dev' } when running outside a Docker build (local dev).
+let BUILD_VERSION = { sha: 'dev', shortSha: 'dev', buildTime: null };
+try {
+ BUILD_VERSION = require('./client/dist/version.json');
+} catch (_) { /* pre-build or local dev — stub values are fine */ }
+
+// Health
+app.get('/api/health', (req, res) => res.json({
+ status: 'ok',
+ timestamp: new Date().toISOString(),
+ version: BUILD_VERSION,
+}));
+
+// ── Employees ────────────────────────────────────────────────────────────────
+app.get('/api/employees', (req, res) => {
+ const rows = db.prepare('SELECT id, name, department, supervisor, notes FROM employees ORDER BY name ASC').all();
+ res.json(rows);
+});
+
+app.post('/api/employees', (req, res) => {
+ const { name, department, supervisor } = req.body;
+ if (!name) return res.status(400).json({ error: 'name is required' });
+ const existing = db.prepare('SELECT * FROM employees WHERE LOWER(name) = LOWER(?)').get(name);
+ if (existing) {
+ if (department || supervisor) {
+ db.prepare('UPDATE employees SET department = COALESCE(?, department), supervisor = COALESCE(?, supervisor) WHERE id = ?')
+ .run(department || null, supervisor || null, existing.id);
+ }
+ return res.json({ ...existing, department, supervisor });
+ }
+ const result = db.prepare('INSERT INTO employees (name, department, supervisor) VALUES (?, ?, ?)')
+ .run(name, department || null, supervisor || null);
+ audit('employee_created', 'employee', result.lastInsertRowid, null, { name });
+ res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor });
+});
+
+// ── Employee Edit ────────────────────────────────────────────────────────────
+// PATCH /api/employees/:id — update name, department, supervisor, or notes
+app.patch('/api/employees/:id', (req, res) => {
+ const id = parseInt(req.params.id);
+ const emp = db.prepare('SELECT * FROM employees WHERE id = ?').get(id);
+ if (!emp) return res.status(404).json({ error: 'Employee not found' });
+
+ const { name, department, supervisor, notes, performed_by } = req.body;
+
+ // Prevent name collision with a different employee
+ if (name && name.trim() !== emp.name) {
+ const clash = db.prepare('SELECT id FROM employees WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), id);
+ if (clash) return res.status(409).json({ error: 'An employee with that name already exists', conflictId: clash.id });
+ }
+
+ const newName = (name || emp.name).trim();
+ const newDept = department !== undefined ? (department || null) : emp.department;
+ const newSupervisor = supervisor !== undefined ? (supervisor || null) : emp.supervisor;
+ const newNotes = notes !== undefined ? (notes || null) : emp.notes;
+
+ db.prepare('UPDATE employees SET name = ?, department = ?, supervisor = ?, notes = ? WHERE id = ?')
+ .run(newName, newDept, newSupervisor, newNotes, id);
+
+ audit('employee_edited', 'employee', id, performed_by, {
+ before: { name: emp.name, department: emp.department, supervisor: emp.supervisor, notes: emp.notes },
+ after: { name: newName, department: newDept, supervisor: newSupervisor, notes: newNotes },
+ });
+
+ res.json({ id, name: newName, department: newDept, supervisor: newSupervisor, notes: newNotes });
+});
+
+// ── Employee Merge ───────────────────────────────────────────────────────────
+// POST /api/employees/:id/merge — reassign all violations from sourceId → id, then delete source
+app.post('/api/employees/:id/merge', (req, res) => {
+ const targetId = parseInt(req.params.id);
+ const { source_id, performed_by } = req.body;
+ if (!source_id) return res.status(400).json({ error: 'source_id is required' });
+
+ const target = db.prepare('SELECT * FROM employees WHERE id = ?').get(targetId);
+ const source = db.prepare('SELECT * FROM employees WHERE id = ?').get(source_id);
+ if (!target) return res.status(404).json({ error: 'Target employee not found' });
+ if (!source) return res.status(404).json({ error: 'Source employee not found' });
+ if (targetId === parseInt(source_id)) return res.status(400).json({ error: 'Cannot merge an employee into themselves' });
+
+ const mergeTransaction = db.transaction(() => {
+ // Move all violations
+ const moved = db.prepare('UPDATE violations SET employee_id = ? WHERE employee_id = ?').run(targetId, source_id);
+ // Delete the source employee
+ db.prepare('DELETE FROM employees WHERE id = ?').run(source_id);
+ return moved.changes;
+ });
+
+ const violationsMoved = mergeTransaction();
+
+ audit('employee_merged', 'employee', targetId, performed_by, {
+ source: { id: source.id, name: source.name },
+ target: { id: target.id, name: target.name },
+ violations_reassigned: violationsMoved,
+ });
+
+ res.json({ success: true, violations_reassigned: violationsMoved });
+});
+
+// ── Employee notes (PATCH shorthand) ─────────────────────────────────────────
+// PATCH /api/employees/:id/notes — save free-text notes only
+app.patch('/api/employees/:id/notes', (req, res) => {
+ const id = parseInt(req.params.id);
+ const emp = db.prepare('SELECT * FROM employees WHERE id = ?').get(id);
+ if (!emp) return res.status(404).json({ error: 'Employee not found' });
+
+ const { notes, performed_by } = req.body;
+ const newNotes = notes !== undefined ? (notes || null) : emp.notes;
+
+ db.prepare('UPDATE employees SET notes = ? WHERE id = ?').run(newNotes, id);
+ audit('employee_notes_updated', 'employee', id, performed_by, { notes: newNotes });
+ res.json({ id, notes: newNotes });
+});
+
+// Employee score (current snapshot) — includes total violations + negated count
+app.get('/api/employees/:id/score', (req, res) => {
+ const empId = req.params.id;
+
+ // Active points from the 90-day rolling view
+ const active = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(empId);
+
+ // Total violations (all time) and negated count
+ const totals = db.prepare(`
+ SELECT
+ COUNT(*) AS total_violations,
+ COALESCE(SUM(negated), 0) AS negated_count
+ FROM violations
+ WHERE employee_id = ?
+ `).get(empId);
+
+ res.json({
+ employee_id: empId,
+ active_points: active ? active.active_points : 0,
+ violation_count: active ? active.violation_count : 0,
+ total_violations: totals ? totals.total_violations : 0,
+ negated_count: totals ? totals.negated_count : 0,
+ });
+});
+
+// ── Expiration Timeline ──────────────────────────────────────────────────────
+// GET /api/employees/:id/expiration — active violations sorted by roll-off date
+// Returns each active violation with days_remaining until it exits the 90-day window.
+app.get('/api/employees/:id/expiration', (req, res) => {
+ const rows = db.prepare(`
+ SELECT
+ v.id,
+ v.violation_name,
+ v.violation_type,
+ v.category,
+ v.points,
+ v.incident_date,
+ DATE(v.incident_date, '+90 days') AS expires_on,
+ CAST(
+ JULIANDAY(DATE(v.incident_date, '+90 days')) -
+ JULIANDAY(DATE('now'))
+ AS INTEGER
+ ) AS days_remaining
+ FROM violations v
+ WHERE v.employee_id = ?
+ AND v.negated = 0
+ AND v.incident_date >= DATE('now', '-90 days')
+ ORDER BY v.incident_date ASC
+ `).all(req.params.id);
+ res.json(rows);
+});
+
+// Dashboard
+app.get('/api/dashboard', (req, res) => {
+ const rows = db.prepare(`
+ SELECT e.id, e.name, e.department, e.supervisor,
+ COALESCE(s.active_points, 0) AS active_points,
+ COALESCE(s.violation_count,0) AS violation_count
+ FROM employees e
+ LEFT JOIN active_cpas_scores s ON s.employee_id = e.id
+ ORDER BY active_points DESC, e.name ASC
+ `).all();
+ res.json(rows);
+});
+
+// Violation history (per employee) with resolutions + amendment count
+app.get('/api/violations/employee/:id', (req, res) => {
+ const limit = parseInt(req.query.limit) || 50;
+ const rows = db.prepare(`
+ SELECT v.*, r.resolution_type, r.details AS resolution_details,
+ r.resolved_by, r.created_at AS resolved_at,
+ (SELECT COUNT(*) FROM violation_amendments a WHERE a.violation_id = v.id) AS amendment_count
+ FROM violations v
+ LEFT JOIN violation_resolutions r ON r.violation_id = v.id
+ WHERE v.employee_id = ?
+ ORDER BY v.incident_date DESC, v.created_at DESC
+ LIMIT ?
+ `).all(req.params.id, limit);
+ res.json(rows);
+});
+
+// ── Violation amendment history ──────────────────────────────────────────────
+app.get('/api/violations/:id/amendments', (req, res) => {
+ const rows = db.prepare(`
+ SELECT * FROM violation_amendments WHERE violation_id = ? ORDER BY created_at DESC
+ `).all(req.params.id);
+ res.json(rows);
+});
+
+// Helper: compute prior_active_points at time of insert
+function getPriorActivePoints(employeeId, incidentDate) {
+ const row = db.prepare(
+ `SELECT COALESCE(SUM(points),0) AS pts
+ FROM violations
+ WHERE employee_id = ?
+ AND negated = 0
+ AND incident_date >= DATE(?, '-90 days')
+ AND incident_date < ?`
+ ).get(employeeId, incidentDate, incidentDate);
+ return row ? row.pts : 0;
+}
+
+// POST new violation
+app.post('/api/violations', (req, res) => {
+ const {
+ employee_id, violation_type, violation_name, category,
+ points, incident_date, incident_time, location,
+ details, submitted_by, witness_name,
+ acknowledged_by, acknowledged_date
+ } = req.body;
+
+ if (!employee_id || !violation_type || !points || !incident_date) {
+ return res.status(400).json({ error: 'Missing required fields' });
+ }
+
+ const ptsInt = parseInt(points);
+ const priorPts = getPriorActivePoints(employee_id, incident_date);
+
+ const result = db.prepare(`
+ INSERT INTO violations (
+ employee_id, violation_type, violation_name, category,
+ points, incident_date, incident_time, location,
+ details, submitted_by, witness_name,
+ prior_active_points,
+ acknowledged_by, acknowledged_date
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(
+ employee_id, violation_type, violation_name || violation_type,
+ category || 'General', ptsInt, incident_date,
+ incident_time || null, location || null,
+ details || null, submitted_by || null, witness_name || null,
+ priorPts,
+ acknowledged_by || null, acknowledged_date || null
+ );
+
+ audit('violation_created', 'violation', result.lastInsertRowid, submitted_by, {
+ employee_id, violation_type, points: ptsInt, incident_date,
+ });
+
+ res.status(201).json({ id: result.lastInsertRowid });
+});
+
+// ── Violation Amendment (edit) ───────────────────────────────────────────────
+// PATCH /api/violations/:id/amend — edit mutable fields; logs a diff per changed field
+const AMENDABLE_FIELDS = ['incident_time', 'location', 'details', 'submitted_by', 'witness_name', 'acknowledged_by', 'acknowledged_date'];
+
+app.patch('/api/violations/:id/amend', (req, res) => {
+ const id = parseInt(req.params.id);
+ const { changed_by, ...updates } = req.body;
+
+ const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
+ if (!violation) return res.status(404).json({ error: 'Violation not found' });
+ if (violation.negated) return res.status(400).json({ error: 'Cannot amend a negated violation' });
+
+ // Only allow whitelisted fields to be amended
+ const allowed = Object.fromEntries(
+ Object.entries(updates).filter(([k]) => AMENDABLE_FIELDS.includes(k))
+ );
+ if (Object.keys(allowed).length === 0) {
+ return res.status(400).json({ error: 'No amendable fields provided', amendable: AMENDABLE_FIELDS });
+ }
+
+ const amendTransaction = db.transaction(() => {
+ // Build UPDATE
+ const setClauses = Object.keys(allowed).map(k => `${k} = ?`).join(', ');
+ const values = [...Object.values(allowed), id];
+ db.prepare(`UPDATE violations SET ${setClauses} WHERE id = ?`).run(...values);
+
+ // Insert an amendment record per changed field
+ const insertAmendment = db.prepare(`
+ INSERT INTO violation_amendments (violation_id, changed_by, field_name, old_value, new_value)
+ VALUES (?, ?, ?, ?, ?)
+ `);
+ for (const [field, newVal] of Object.entries(allowed)) {
+ const oldVal = violation[field];
+ if (String(oldVal) !== String(newVal)) {
+ insertAmendment.run(id, changed_by || null, field, oldVal ?? null, newVal ?? null);
+ }
+ }
+ });
+
+ amendTransaction();
+
+ audit('violation_amended', 'violation', id, changed_by, { fields: Object.keys(allowed) });
+
+ const updated = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
+ res.json(updated);
+});
+
+// ── Negate a violation ───────────────────────────────────────────────────────
+app.patch('/api/violations/:id/negate', (req, res) => {
+ const { resolution_type, details, resolved_by } = req.body;
+ const id = req.params.id;
+
+ const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
+ if (!violation) return res.status(404).json({ error: 'Violation not found' });
+
+ db.prepare('UPDATE violations SET negated = 1 WHERE id = ?').run(id);
+
+ const existing = db.prepare('SELECT id FROM violation_resolutions WHERE violation_id = ?').get(id);
+ if (existing) {
+ db.prepare(`
+ UPDATE violation_resolutions
+ SET resolution_type = ?, details = ?, resolved_by = ?, created_at = datetime('now')
+ WHERE violation_id = ?
+ `).run(resolution_type || 'Resolved', details || null, resolved_by || null, id);
+ } else {
+ db.prepare(`
+ INSERT INTO violation_resolutions (violation_id, resolution_type, details, resolved_by)
+ VALUES (?, ?, ?, ?)
+ `).run(id, resolution_type || 'Resolved', details || null, resolved_by || null);
+ }
+
+ audit('violation_negated', 'violation', id, resolved_by, { resolution_type });
+ res.json({ success: true });
+});
+
+// ── Restore a negated violation ──────────────────────────────────────────────
+app.patch('/api/violations/:id/restore', (req, res) => {
+ const id = req.params.id;
+
+ const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
+ if (!violation) return res.status(404).json({ error: 'Violation not found' });
+
+ db.prepare('UPDATE violations SET negated = 0 WHERE id = ?').run(id);
+ db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id);
+
+ audit('violation_restored', 'violation', id, req.body?.performed_by, {});
+ res.json({ success: true });
+});
+
+// ── Hard delete a violation ──────────────────────────────────────────────────
+app.delete('/api/violations/:id', (req, res) => {
+ const id = req.params.id;
+
+ const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
+ if (!violation) return res.status(404).json({ error: 'Violation not found' });
+
+ db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id);
+ db.prepare('DELETE FROM violations WHERE id = ?').run(id);
+
+ audit('violation_deleted', 'violation', id, req.body?.performed_by, {
+ violation_type: violation.violation_type, employee_id: violation.employee_id,
+ });
+ res.json({ success: true });
+});
+
+// ── Audit log ────────────────────────────────────────────────────────────────
+app.get('/api/audit', (req, res) => {
+ const limit = Math.min(parseInt(req.query.limit) || 100, 500);
+ const offset = parseInt(req.query.offset) || 0;
+ const type = req.query.entity_type;
+ const id = req.query.entity_id;
+
+ let sql = 'SELECT * FROM audit_log';
+ const args = [];
+ const where = [];
+ if (type) { where.push('entity_type = ?'); args.push(type); }
+ if (id) { where.push('entity_id = ?'); args.push(id); }
+ if (where.length) sql += ' WHERE ' + where.join(' AND ');
+ sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
+ args.push(limit, offset);
+
+ res.json(db.prepare(sql).all(...args));
+});
+
+// ── Custom Violation Types ────────────────────────────────────────────────────
+// Persisted violation type definitions stored in violation_types table.
+// type_key is auto-generated (custom_) to avoid collisions with hardcoded keys.
+
+app.get('/api/violation-types', (req, res) => {
+ const rows = db.prepare('SELECT * FROM violation_types ORDER BY category ASC, name ASC').all();
+ res.json(rows.map(r => ({ ...r, fields: JSON.parse(r.fields) })));
+});
+
+app.post('/api/violation-types', (req, res) => {
+ const { name, category, chapter, description, min_points, max_points, fields, created_by } = req.body;
+ if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
+
+ const minPts = parseInt(min_points) || 1;
+ const maxPts = parseInt(max_points) || minPts;
+ if (maxPts < minPts) return res.status(400).json({ error: 'max_points must be >= min_points' });
+
+ // Generate a unique type_key from the name, prefixed with 'custom_'
+ const base = 'custom_' + name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
+ let typeKey = base;
+ let suffix = 2;
+ while (db.prepare('SELECT id FROM violation_types WHERE type_key = ?').get(typeKey)) {
+ typeKey = `${base}_${suffix++}`;
+ }
+
+ try {
+ const result = db.prepare(`
+ INSERT INTO violation_types (type_key, name, category, chapter, description, min_points, max_points, fields)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(
+ typeKey,
+ name.trim(),
+ (category || 'Custom').trim(),
+ chapter || null,
+ description || null,
+ minPts,
+ maxPts,
+ JSON.stringify(fields && fields.length ? fields : ['description'])
+ );
+ const row = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(result.lastInsertRowid);
+ audit('violation_type_created', 'violation_type', result.lastInsertRowid, created_by || null, { name: row.name, category: row.category });
+ res.status(201).json({ ...row, fields: JSON.parse(row.fields) });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.put('/api/violation-types/:id', (req, res) => {
+ const id = parseInt(req.params.id);
+ const row = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(id);
+ if (!row) return res.status(404).json({ error: 'Violation type not found' });
+
+ const { name, category, chapter, description, min_points, max_points, fields, updated_by } = req.body;
+ if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
+
+ const minPts = parseInt(min_points) || 1;
+ const maxPts = parseInt(max_points) || minPts;
+ if (maxPts < minPts) return res.status(400).json({ error: 'max_points must be >= min_points' });
+
+ db.prepare(`
+ UPDATE violation_types
+ SET name=?, category=?, chapter=?, description=?, min_points=?, max_points=?, fields=?, updated_at=CURRENT_TIMESTAMP
+ WHERE id=?
+ `).run(
+ name.trim(),
+ (category || 'Custom').trim(),
+ chapter || null,
+ description || null,
+ minPts,
+ maxPts,
+ JSON.stringify(fields && fields.length ? fields : ['description']),
+ id
+ );
+
+ const updated = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(id);
+ audit('violation_type_updated', 'violation_type', id, updated_by || null, { name: updated.name, category: updated.category });
+ res.json({ ...updated, fields: JSON.parse(updated.fields) });
+});
+
+app.delete('/api/violation-types/:id', (req, res) => {
+ const id = parseInt(req.params.id);
+ const row = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(id);
+ if (!row) return res.status(404).json({ error: 'Violation type not found' });
+
+ const usage = db.prepare('SELECT COUNT(*) as count FROM violations WHERE violation_type = ?').get(row.type_key);
+ if (usage.count > 0) {
+ return res.status(409).json({ error: `Cannot delete: ${usage.count} violation(s) reference this type. Negate those violations first.` });
+ }
+
+ db.prepare('DELETE FROM violation_types WHERE id = ?').run(id);
+ audit('violation_type_deleted', 'violation_type', id, null, { name: row.name, type_key: row.type_key });
+ res.json({ ok: true });
+});
+
+// ── PDF endpoint ─────────────────────────────────────────────────────────────
+app.get('/api/violations/:id/pdf', async (req, res) => {
+ try {
+ const violation = db.prepare(`
+ SELECT v.*, e.name as employee_name, e.department, e.supervisor
+ FROM violations v
+ JOIN employees e ON e.id = v.employee_id
+ WHERE v.id = ?
+ `).get(req.params.id);
+
+ if (!violation) return res.status(404).json({ error: 'Violation not found' });
+
+ const active = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?')
+ .get(violation.employee_id) || { active_points: 0, violation_count: 0 };
+
+ const scoreForPdf = {
+ employee_id: violation.employee_id,
+ active_points: violation.prior_active_points != null ? violation.prior_active_points : active.active_points,
+ violation_count: active.violation_count,
+ };
+
+ const pdfBuffer = await generatePdf(violation, scoreForPdf);
+ const safeName = violation.employee_name.replace(/[^a-z0-9]/gi, '_');
+
+ res.set({
+ 'Content-Type': 'application/pdf',
+ 'Content-Disposition': `attachment; filename="CPAS_${safeName}_${violation.incident_date}.pdf"`,
+ 'Content-Length': pdfBuffer.length,
+ });
+ res.end(pdfBuffer);
+ } catch (err) {
+ console.error('[PDF]', err);
+ res.status(500).json({ error: 'PDF generation failed', detail: err.message });
+ }
+});
+
+// SPA fallback
+app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'client', 'dist', 'index.html')));
+
+app.listen(PORT, '0.0.0.0', () => console.log(`[CPAS] Server running on port ${PORT}`));
diff --git a/AGENTS.md b/AGENTS.md
index 45b7ba8..b780131 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -92,6 +92,8 @@ Violations are **never hard-deleted** in normal workflow. Use the `negated` flag
Every `INSERT` into `violations` must compute and store `prior_active_points` (the employee's current active score before this violation is added). This snapshot ensures PDFs always reflect the accurate historical tier state regardless of subsequent negate/restore actions.
+**Back-dated inserts are the one exception to snapshot immutability.** If a new violation's `incident_date` precedes existing violations within the 90-day window, those existing violations' snapshots are recomputed via `recomputeSnapshotsAfter()` inside the same transaction as the insert, and a `violation_snapshots_recomputed` audit entry is written. A back-dated insert is a *timeline rewrite* — the prior violations genuinely had an earlier event in their 90-day window — so their PDFs must reflect that. Negate/restore are NOT timeline rewrites and must never recompute snapshots.
+
### Audit Log
Every write action (employee created/edited/merged, violation logged/amended/negated/restored/deleted) must call the `audit()` helper in `server.js`. Never skip audit calls on write routes. The audit log is append-only — no UPDATE or DELETE against `audit_log`.
@@ -247,7 +249,7 @@ docker run -d --name cpas -p 3001:3001 -v cpas-data:/data cpas
### What NOT to Do
- Do not compute active CPAS scores in JavaScript by summing violations client-side. Always fetch from the `active_cpas_scores` view.
-- Do not modify `prior_active_points` after a violation is inserted. It is a historical snapshot, not a live value.
+- Do not modify `prior_active_points` after a violation is inserted, EXCEPT when a back-dated insert retroactively places a new earlier event into another violation's 90-day prior window. That path is handled by `recomputeSnapshotsAfter()` in `server.js` and is audit-logged. Never recompute snapshots on negate, restore, amend, or hard delete.
- Do not add columns to `audit_log`. It is append-only with a fixed schema.
- Do not add a framework or ORM. Raw SQL with prepared statements is intentional — it keeps the query behavior explicit and the dependency surface small.
- Do not add a build step beyond `vite build`. The backend is plain CommonJS `require()`; do not transpile it.
diff --git a/CPAS Violation Tracker.md b/CPAS Violation Tracker.md
new file mode 100644
index 0000000..8cfba15
--- /dev/null
+++ b/CPAS Violation Tracker.md
@@ -0,0 +1,141 @@
+---
+type: project
+status: active
+tags: [project, cpas, hr, internal-tool, docker, react, node, sqlite]
+repo: https://git.alwisp.com/jason/cpas
+started: 2026-03-06
+updated: 2026-05-14
+owner: Jason Stedwell
+---
+
+# CPAS Violation Tracker
+
+Single-container Dockerized web app for **CPAS** (workforce standing / violation documentation) used internally at Message Point Media. Replaces ad-hoc paper / spreadsheet violation tracking with a structured, auditable system that produces signed PDF records and surfaces tier-escalation risk live.
+
+> Repo: [git.alwisp.com/jason/cpas](https://git.alwisp.com/jason/cpas) · First commit: 2026-03-06 · Deployed on Unraid (`10.2.0.14:3001`)
+
+## Stack
+
+- **Frontend:** React + Vite (single-page app, dark theme, mobile-responsive at 375px+)
+- **Backend:** Node.js + Express
+- **DB:** SQLite via `better-sqlite3`, WAL mode, auto-migrations on boot
+- **PDF:** Puppeteer + bundled Chromium (system install inside container)
+- **Packaging:** One multi-stage Dockerfile — build React, install backend, bundle Chromium, ship as a single image
+- **Runtime requirement on dev box:** Docker Desktop only. No host Node/npm needed.
+
+## What it does
+
+It manages **employee violations against the CPAS rubric** on a rolling 90-day window:
+
+- **Company Dashboard** — every employee sorted by active CPAS points (highest risk first), with summary stat cards, tier badges, an "at-risk" flag (within 2 pts of next tier), and search + department filter.
+- **Violation Form** — pick an employee, pick a violation type, see prior 90-day count inline; recidivist auto-escalation; pre-submit tier-crossing warning; context-aware fields; one-click PDF download on submit; optional employee acknowledgment block.
+- **Employee Profile Modal** — full violation history, amendment count, edit employee, merge duplicate, negate/restore, hard delete, per-violation PDF, free-text notes/flags ("on PIP", "union member"), and a per-violation 90-day **point expiration timeline** with projected tier drops.
+- **Violation Amendment** — point value / type / incident date are immutable; non-scoring fields (location, witness, narrative, acknowledgment) are amendable with a field-level diff trail.
+- **Audit Log** — append-only record of every write action (employee CRUD, violation logged/amended/negated/restored/deleted); filterable, paginated panel from the dashboard.
+- **Toast notification system** — global success/error/warning/info, auto-dismiss with progress bar.
+- **PDF generation** — Puppeteer, snapshot of prior active points at incident time, optional employee acknowledgment block on the signature page.
+- **Stakeholder demo** — `/demo` static route with synthetic data, served before the SPA catch-all; no auth required.
+
+## CPAS Tier System
+
+| Points | Tier | Label |
+| ------ | ---- | ----------------------- |
+| 0–4 | 0–1 | Elite Standing |
+| 5–9 | 1 | Realignment |
+| 10–14 | 2 | Administrative Lockdown |
+| 15–19 | 3 | Verification |
+| 20–24 | 4 | Risk Mitigation |
+| 25–29 | 5 | Final Decision |
+| 30+ | 6 | Separation |
+
+Scores are summed over a **rolling 90-day window**; negated violations excluded.
+
+## Database
+
+Six tables + one view:
+
+- `employees` — id, name, department, supervisor, notes
+- `violations` — full incident record, including `prior_active_points` snapshot
+- `violation_resolutions` — soft-delete reason / details
+- `violation_amendments` — field-level diff log (one row per changed field)
+- `audit_log` — append-only system action log
+- `active_cpas_scores` (view) — 90-day point sum per employee
+
+Auto-migrations in `db/database.js` add new columns to existing DBs on startup — meaningful here because Jason runs this in production on Unraid, so the schema evolves without losing data.
+
+## Deployment notes worth remembering
+
+- **Unraid:** static IP on `br0` bridge (`10.2.0.14`), DB persisted at `/mnt/user/appdata/cpas/db/cpas.db`, WebUI on port 3001.
+- **`--pids-limit 2048` is critical** — Puppeteer/Chromium spawns many processes for each PDF; Unraid's default cap silently kills PDF generation.
+- **Volume:** `/mnt/user/appdata/cpas/db` → `/data`. Database survives rebuilds, image reloads, and container removal.
+- Updates are a 3-step loop: `docker build` locally → `docker save | gzip` → SMB drop into appdata → `docker load` + restart container in Unraid GUI.
+
+## Mobile
+
+Responsive design targets 375px+ (iPhone SE and up). At ≤768px the dashboard table swaps to a card-based layout (`DashboardMobile.jsx`); the nav stacks; tap targets are ≥44px; form inputs use 16px font to prevent iOS focus zoom. No external CSS library — single `mobile.css` utility sheet + a `useMediaQuery` hook. Implementation details and testing checklist live in `MOBILE_RESPONSIVE.md`.
+
+## Project structure
+
+```
+cpas/
+├── Dockerfile # Multi-stage: React build + Express + Chromium
+├── server.js # API + static SPA + /demo route
+├── db/
+│ ├── schema.sql # Tables + 90-day active score view
+│ └── database.js # SQLite + auto-migrations
+├── pdf/
+│ ├── generator.js # Puppeteer
+│ └── template.js # HTML PDF template
+├── demo/ # /demo synthetic-data SPA
+└── client/ # React + Vite frontend
+ └── src/
+ ├── App.jsx # Root + footer (copyright, dev ticker, Gitea link)
+ ├── data/
+ │ ├── violations.js # All CPAS violation definitions
+ │ └── departments.js
+ ├── hooks/useEmployeeIntelligence.js
+ └── components/ # Dashboard, ViolationForm, EmployeeModal,
+ # AmendViolationModal, AuditLog, ToastProvider, etc.
+```
+
+## API surface (selected)
+
+- `GET /api/health` — health + build version
+- `GET /api/dashboard` — all employees with active points + violation counts
+- `GET /api/employees/:id/expiration` — roll-off timeline with days remaining
+- `POST /api/violations` — log violation (accepts `acknowledged_by`, `acknowledged_date`)
+- `PATCH /api/violations/:id/amend` — non-scoring field amendment + diff log
+- `PATCH /api/violations/:id/negated` / `/restore` — soft delete + restore
+- `GET /api/violations/:id/pdf` — PDF download
+- `GET /api/audit` — paginated audit log
+
+## Status
+
+Phase 8 of the public roadmap is complete (stakeholder demo + app footer with live dev ticker). Core HR documentation workflow is shipped: dashboard, violation entry, employee profile, amendments, audit log, expiration timeline, acknowledgment field, toast system, mobile layout.
+
+## Notable open ideas (from roadmap)
+
+**Quick wins (low effort):** column sort on dashboard, department multi-select filter, `N` keyboard shortcut for new violation, configurable at-risk threshold via env var, version.json injected at build time.
+
+**Reporting:** violation trend chart (daily/weekly/monthly), department heat map, per-employee sparklines in profile modal.
+
+**Workflow:** draft/pending violations before finalize, violation templates.
+
+**Notifications:** tier-escalation alerts (email or in-app) on crossing into Tier 2+.
+
+**Infra (higher effort):** multi-user auth with roles (currently runs on trusted internal LAN with none), scheduled DB backup, dark/light theme toggle.
+
+## Review take
+
+The app is well-scoped and visibly production-shaped, not a hobby project. Strong signals: append-only audit log, field-level amendment diffs, immutable scoring fields, `prior_active_points` snapshot baked into each violation row so historical PDFs stay accurate, and auto-migrations so the schema can evolve in place on a live Unraid deployment. The Unraid-specific install guide explicitly calls out `--pids-limit 2048` for Chromium, which is the kind of footgun that only gets documented after it's been hit in production — that detail alone tells you this is being actually used.
+
+The single biggest gap relative to its current capability is **auth**. Everything else on the roadmap is incremental polish; multi-user auth with roles is the one change that meaningfully expands who can use the system safely. Worth pairing it with the proposed scheduled DB backup, since the moment more than one supervisor is writing, accidental damage gets more likely.
+
+A secondary observation: the audit log + amendment diff infrastructure is already strong enough to support a "who changed what, when" view per employee — that's basically free reporting if surfaced in the profile modal as a timeline.
+
+## Links
+
+- Repo — [git.alwisp.com/jason/cpas](https://git.alwisp.com/jason/cpas)
+- Local install guide — `README.md` (in repo)
+- Unraid install guide — `README_UNRAID_INSTALL.md`
+- Mobile implementation notes — `MOBILE_RESPONSIVE.md`
diff --git a/server.js b/server.js
index 65b89f5..a6a8f04 100755
--- a/server.js
+++ b/server.js
@@ -260,6 +260,33 @@ function getPriorActivePoints(employeeId, incidentDate) {
return row ? row.pts : 0;
}
+// Helper: after a back-dated insert, refresh snapshots on any existing
+// violations whose 90-day prior-window now includes the new (earlier)
+// incident_date. Without this, their PDFs would still show the pre-backdate
+// "Prior Active Points" and miss the inserted earlier violation.
+// Snapshots are still immutable w.r.t. negate/restore — only timeline-
+// rewriting events (back-dated inserts) trigger a refresh.
+function recomputeSnapshotsAfter(employeeId, incidentDate) {
+ const affected = db.prepare(`
+ SELECT id, incident_date, prior_active_points
+ FROM violations
+ WHERE employee_id = ?
+ AND incident_date > ?
+ AND incident_date <= DATE(?, '+90 days')
+ `).all(employeeId, incidentDate, incidentDate);
+
+ const updateStmt = db.prepare('UPDATE violations SET prior_active_points = ? WHERE id = ?');
+ const changes = [];
+ for (const v of affected) {
+ const newPrior = getPriorActivePoints(employeeId, v.incident_date);
+ if (newPrior !== v.prior_active_points) {
+ updateStmt.run(newPrior, v.id);
+ changes.push({ id: v.id, incident_date: v.incident_date, old: v.prior_active_points, new: newPrior });
+ }
+ }
+ return changes;
+}
+
// POST new violation
app.post('/api/violations', (req, res) => {
const {
@@ -275,32 +302,51 @@ app.post('/api/violations', (req, res) => {
}
const ptsInt = parseInt(points);
- const priorPts = getPriorActivePoints(employee_id, incident_date);
- const result = db.prepare(`
- INSERT INTO violations (
- employee_id, violation_type, violation_name, category,
- points, incident_date, incident_time, location,
- details, submitted_by, witness_name,
- prior_active_points,
- acknowledged_by, acknowledged_date,
- amount
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `).run(
- employee_id, violation_type, violation_name || violation_type,
- category || 'General', ptsInt, incident_date,
- incident_time || null, location || null,
- details || null, submitted_by || null, witness_name || null,
- priorPts,
- acknowledged_by || null, acknowledged_date || null,
- amount || null
- );
+ // Insert + downstream snapshot refresh run in a single transaction so a
+ // failed recompute can't leave the system with a new violation and stale
+ // sibling snapshots.
+ const insertTxn = db.transaction(() => {
+ const priorPts = getPriorActivePoints(employee_id, incident_date);
+ const result = db.prepare(`
+ INSERT INTO violations (
+ employee_id, violation_type, violation_name, category,
+ points, incident_date, incident_time, location,
+ details, submitted_by, witness_name,
+ prior_active_points,
+ acknowledged_by, acknowledged_date,
+ amount
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(
+ employee_id, violation_type, violation_name || violation_type,
+ category || 'General', ptsInt, incident_date,
+ incident_time || null, location || null,
+ details || null, submitted_by || null, witness_name || null,
+ priorPts,
+ acknowledged_by || null, acknowledged_date || null,
+ amount || null
+ );
+ const refreshed = recomputeSnapshotsAfter(employee_id, incident_date);
+ return { id: result.lastInsertRowid, refreshed };
+ });
- audit('violation_created', 'violation', result.lastInsertRowid, submitted_by, {
+ const { id: newId, refreshed } = insertTxn();
+
+ audit('violation_created', 'violation', newId, submitted_by, {
employee_id, violation_type, points: ptsInt, incident_date,
});
- res.status(201).json({ id: result.lastInsertRowid });
+ // Back-dated insert: log the snapshot refresh so the audit trail explains
+ // why downstream violations' PDFs now show different "Prior Active Points".
+ if (refreshed.length > 0) {
+ audit('violation_snapshots_recomputed', 'violation', newId, submitted_by, {
+ reason: 'backdated_insert',
+ trigger_incident_date: incident_date,
+ affected: refreshed,
+ });
+ }
+
+ res.status(201).json({ id: newId });
});
// ── Violation Amendment (edit) ───────────────────────────────────────────────