Files
cpas/AGENTS.md
T
Jason Stedwell aef4ae60cb
Build and Push Docker Image / build (push) Successful in 49s
Docs consolidation + audit-log timezone fix + PTO-Exhausted sliding scale
Documentation:
- Consolidate 6 doc surfaces to 3. Merge Unraid guide into README and
  fold durable mobile rules into AGENTS.md; remove README_UNRAID_INSTALL.md,
  MOBILE_RESPONSIVE.md, and the misplaced "CPAS Violation Tracker.md" vault note.
- Fix drift across README/AGENTS/in-app guide: clean-cycle roll-off model
  (not per-violation 90-day window), auth documented as shipped, dropped
  active_cpas_scores view, correct table count (8 tables/0 views), auth
  endpoints + ADMIN_PASSWORD in run commands, roadmap updated.

Fixes:
- Audit log / amendment timestamps: SQLite CURRENT_TIMESTAMP is UTC with no
  zone marker; new Date() parsed the space-separated form as local time,
  shifting entries +5h in Chicago/CDT. Pin the bare form to UTC before
  formatting in AuditLog.jsx and AmendViolationModal.jsx.
- Absence - PTO Exhausted: fixed 5 pts -> sliding scale 1-5.

Rebuild client bundle to include the above.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:07:51 -05:00

342 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md — CPAS Violation Tracker
Developer and AI agent guidance for working on this codebase. Read this before making changes.
---
## Project Purpose
CPAS (Corrective & Progressive Accountability System) is an internal HR tool for documenting employee violations, managing disciplinary tier escalation via a rolling 90-day point system, and producing auditable PDF records. It is a single-container Docker app deployed on a trusted internal network.
**This is a compliance tool.** Data integrity, auditability, and reversibility are first-class concerns. Every architectural decision below exists for a reason.
---
## Stack at a Glance
| Layer | Tech |
|---|---|
| Frontend | React 18 + Vite (SPA, served statically by Express) |
| Backend | Node.js + Express (REST API, `server.js`) |
| Database | SQLite via `better-sqlite3` (synchronous, WAL mode, FK enforcement) |
| PDF | Puppeteer + system Chromium (Alpine-bundled in Docker) |
| Styling | Inline React style objects; `client/src/styles/mobile.css` for breakpoints only |
| Deploy | Docker multi-stage build (Alpine); single container + volume mount at `/data` |
---
## Repository Layout
```
cpas/
├── Dockerfile # Multi-stage: builder (Node+React) → production (Alpine+Chromium)
├── server.js # All API routes + audit helper; single Express entry point
├── auth.js # scrypt hashing, sessions, bootstrap admin, requireAuth/requireAdmin
├── lib/
│ └── rolloff.js # Clean-cycle point roll-off model (computeStanding); SoT for scores
├── db/
│ ├── schema.sql # Base table + view definitions (CREATE TABLE IF NOT EXISTS)
│ └── database.js # DB connection, WAL/FK pragmas, auto-migrations on startup
├── pdf/
│ ├── generator.js # Puppeteer launcher; --no-sandbox for Docker
│ └── template.js # HTML template builder; loads logo from disk
├── demo/ # Static stakeholder demo page served at /demo
│ └── index.html # Synthetic data, no live API calls; registered before SPA catch-all
├── client/
│ ├── vite.config.js
│ ├── src/
│ │ ├── App.jsx # Root component + AppFooter
│ │ ├── main.jsx # React DOM mount
│ │ ├── data/
│ │ │ ├── violations.js # Canonical violation type registry (type key → metadata)
│ │ │ └── departments.js # DEPARTMENTS constant; single source of truth
│ │ ├── hooks/
│ │ │ └── useEmployeeIntelligence.js # Score + history fetch hook
│ │ ├── components/ # One file per component; no barrel index
│ │ └── styles/
│ │ └── mobile.css # Media query overrides only; all other styles are inline
└── README.md # Install (local + Unraid), features, API reference, schema, roadmap
```
---
## Data Model & Compliance Rules
### Tables
| Table | Purpose |
|---|---|
| `employees` | id, name, department, supervisor, notes |
| `violations` | Full incident record; contains immutable scoring fields |
| `violation_resolutions` | Soft-delete records (resolution type, reason, resolver) |
| `violation_amendments` | Field-level diff per amendment (old → new, changed_by, timestamp) |
| `audit_log` | Append-only write action log; never delete from this table |
| `violation_types` | Persisted custom violation type definitions (UI-created; `type_key` prefixed `custom_`) |
| `users` | Auth accounts: username, scrypt `password_hash`, role (`admin`/`user`) |
| `sessions` | Bearer-token login sessions: token, user_id, expires_at (7-day TTL) |
> **Historical note:** an `active_cpas_scores` SQL view once computed a naive per-violation 90-day sum. It has been **dropped** (see `db/database.js`). CPAS standing is now computed in JavaScript by the clean-cycle roll-off model in `lib/rolloff.js` — see the CPAS Tier System section below.
### Immutable Fields (DO NOT allow amendment of these)
The following fields on `violations` are locked after submission. They are the basis for tier calculation and PDF accuracy. **Never expose them to amendment endpoints:**
- `points`
- `violation_type`
- `violation_name`
- `category`
- `incident_date`
- `prior_active_points` (snapshot at insert time)
- `prior_tier_label`
Amendable fields (non-scoring): `incident_time`, `location`, `details`, `submitted_by`, `witness_name`, `acknowledged_by`, `acknowledged_date`, `amount`
### Soft-Delete Pattern
Violations are **never hard-deleted** in normal workflow. Use the `negated` flag + `violation_resolutions` record. Hard delete is reserved for confirmed data-entry errors and requires explicit user confirmation in the UI.
### Prior-Points Snapshot
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.
**There are exactly two sanctioned paths that may modify `prior_active_points` after insert; both are audit-logged as `violation_snapshots_recomputed`:**
1. **Back-dated insert (automatic).** 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. Logged with `reason: "backdated_insert"`. 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.
2. **Manual backfill (admin-triggered).** `POST /api/employees/:id/recompute-snapshots` calls `recomputeAllSnapshotsForEmployee()` and rewrites every row for that employee from current data. Logged with `reason: "manual_backfill"`. This exists to repair drift from back-dated inserts that happened under older code (before path #1 existed) or any other case where the snapshot diverged from current truth. It is exposed in the UI as the **↻ Backfill Snapshots** button in the Employee Profile Modal next to the Active Violations header. Treat it as a targeted repair tool, not a routine maintenance step.
Negate/restore/amend/hard-delete are NOT timeline rewrites and must never recompute snapshots — PDFs remain stable through those operations by design.
### 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`.
---
## CPAS Tier System
These thresholds are the authoritative values. Any feature touching tiers must use them.
| Points | Tier | Label |
|---|---|---|
| 04 | 0-1 | Elite Standing |
| 59 | 1 | Realignment |
| 1014 | 2 | Administrative Lockdown |
| 1519 | 3 | Verification |
| 2024 | 4 | Risk Mitigation |
| 2529 | 5 | Final Decision |
| 30+ | 6 | Separation |
The canonical tier logic lives in `client/src/components/CpasBadge.jsx` (`TIERS` array, `getTier()`, `getNextTier()`). Do not duplicate this logic elsewhere — import from `CpasBadge`.
### Point roll-off model (clean-cycle)
Active-point math lives in **`lib/rolloff.js`** (`computeStanding()`), not in SQL. The rule is a **clean-cycle roll-off**, NOT a per-violation 90-day window:
- Points retire only after the employee goes a full **90 consecutive days with no new non-negated violation**.
- **Any new violation resets that 90-day clock for the employee's entire balance.**
- Each completed clean cycle removes **5 points, oldest violation first**; each further roll-off requires another fresh 90-day clean stretch.
This is deliberately not "each violation expires 90 days after its own incident date" — one new violation pushes roll-off back for everything. Because the model is order-dependent, it is computed in JS and is the single source of truth for point totals. Do not reintroduce a SQL view or sum points client-side.
---
## Violation Type Registry
All violation types are defined in `client/src/data/violations.js` as `violationData`. Each entry includes:
```js
{
name: string, // Display name
category: string, // Grouping for UI display
minPoints: number, // Slider minimum
maxPoints: number, // Slider maximum (min === max means fixed, no slider)
chapter: string, // Policy chapter reference
fields: string[], // Which context fields to show ('time', 'minutes', 'amount', 'location', 'description')
description: string, // Plain-language definition shown in UI
}
```
To add a new violation type: add an entry to `violationData` with a unique camelCase key. Do not add new categories without confirming with the project owner — categories appear in UI groupings.
---
## Authentication
All auth logic lives in **`auth.js`** (backend) and **`client/src/auth.js`** (token storage + axios interceptors). It is intentionally lightweight for a trusted-LAN deployment.
- **Password hashing**: scrypt, no external dependency. Stored format `scrypt$<saltHex>$<hashHex>`; verified with `crypto.timingSafeEqual`.
- **Sessions**: opaque 32-byte bearer tokens in the `sessions` table, **7-day TTL**. The client stores the token in `localStorage` (`cpas_token`) and an axios request interceptor attaches `Authorization: Bearer <token>`; a response interceptor clears the token and bounces to the login screen on any `401`.
- **Route guard**: `app.use('/api', auth.requireAuth)` protects every `/api/*` route registered after it. The only public routes are `GET /api/health` and `POST /api/auth/login`, registered *before* the guard. Admin-only routes add `auth.requireAdmin`.
- **Bootstrap admin**: created/re-synced every startup from `ADMIN_USERNAME` / `ADMIN_PASSWORD` env vars. Its password is owned by the environment — it cannot be changed from the UI (a UI change is overwritten on next restart). Rotate by changing `ADMIN_PASSWORD` and restarting. The Docker image ships a placeholder `changeme` that **must** be overridden at deploy time.
- **UI-managed users**: admins create/delete users and reset passwords via `/api/users*` (min 6-char passwords, `admin`/`user` roles). These accounts are unaffected by restarts.
- **Audit**: `login_success`, `login_failed`, `user_created`, `user_password_reset`, and `user_deleted` are all written to `audit_log`.
Do not weaken or bypass the global guard, and do not persist tokens anywhere but the existing `localStorage` key.
---
## Coding Standards
### Backend (`server.js`)
- Use `better-sqlite3` synchronous API. No async DB calls. This is intentional — it simplifies route handlers and matches Express's sync error handling.
- All prepared statements use positional `?` parameters. Never interpolate user input into SQL strings.
- Every POST/PUT/PATCH/DELETE route must:
1. Validate required inputs and return `400` with a descriptive `{ error: '...' }` body on failure.
2. Call `audit()` on success.
3. Return `{ error: '...' }` (not HTML) on all error paths.
- Group routes by resource (Employees, Violations, Dashboard, Audit). Match the existing comment banner style: `// ── Resource Name ───`.
- **Auth is global, not per-route.** `app.use('/api', auth.requireAuth)` guards every `/api/*` route registered after it in `server.js`. The only public routes are `GET /api/health` and `POST /api/auth/login`, both registered *before* the guard. Admin-only routes add `auth.requireAdmin` as a second middleware. When adding a route, register it after the guard and let it inherit auth — do not add ad-hoc token checks. See the Authentication section below.
### Frontend (React)
- **Styling**: Use inline style objects defined as a `const s = { ... }` block at the top of each component file. Do not add CSS classes or CSS modules — except for responsive breakpoints which go in `mobile.css`.
- **Data constants**: Import violation types from `../data/violations`, departments from `../data/departments`, tier logic from `./CpasBadge`. Do not hardcode these values in components.
- **Toasts**: Use `useToast()` from `ToastProvider` for all user-facing feedback. Do not use `alert()` or `console.log` for user messages.
- **HTTP**: Use `axios` (already imported in form/modal components). Do not introduce `fetch` unless there is a compelling reason — keep it consistent.
- **State**: Prefer local `useState` over lifting state unless data is needed by multiple unrelated components. The only global context is `ToastProvider`.
- **Component files**: One component per file. Name the file to match the export. No barrel `index.js` files.
### Mobile / responsive
The UI targets phones **375px and up** with no external CSS framework. Durable rules:
- **Breakpoints**: `768px` (phones → card layouts, stacked nav), `480px` (small phones → single-column stats), `375px` (minimum supported width). Tablet tweaks at `1024px`. Put breakpoint rules in `mobile.css`, not inline styles.
- **Layout switch**: at ≤768px the dashboard table swaps to a card layout — desktop renders `<table>` in `Dashboard.jsx`; mobile renders `DashboardMobile.jsx`. Gate with the `isMobile` media-query pattern.
- **Touch & iOS**: tap targets ≥ **44px**; form inputs use **16px** font to stop iOS focus-zoom; use `-webkit-overflow-scrolling: touch` for momentum scroll.
- **`useMediaQuery` hook**: currently defined inline in both `App.jsx` and `Dashboard.jsx` (see TODO #8 to extract to `src/hooks/useMediaQuery.js`). Reuse the extracted hook once it lands rather than copying it a third time.
- Test on a real device — Chrome DevTools device mode does not exercise touch behavior.
### Database Migrations
New columns are added via the auto-migration pattern in `database.js`. Do not modify `schema.sql` for columns that already exist in production. Instead:
```js
// Example: adding a new column to violations
const cols = db.prepare('PRAGMA table_info(violations)').all().map(c => c.name);
if (!cols.includes('new_column')) db.exec("ALTER TABLE violations ADD COLUMN new_column TEXT");
```
Add a comment describing the feature the column enables. `schema.sql` is only for base tables — use it only for brand-new tables.
---
## Schema Changes: Decision Checklist
Before adding a column or table, answer:
1. **Does it affect scoring?** If yes, it must be immutable after insert and included in `prior_active_points` computation logic.
2. **Does it need audit trail?** If it tracks a change to an existing record, add a corresponding entry pattern to `violation_amendments` or `audit_log`.
3. **Is it soft-deletable?** Prefer `negated`/flag patterns over hard deletes for anything HR might need to reverse.
4. **Does it appear on PDFs?** Update `pdf/template.js` to reflect it. Test PDF output after schema changes.
5. **Does it affect point calculations?** If so, update `computeStanding()` in `lib/rolloff.js` — that function is the single source of truth for active-point totals. (There is no longer a SQL scoring view.)
---
## PDF Generation
- PDFs are generated on-demand via `GET /api/violations/:id/pdf`. No pre-caching.
- Template is built in `pdf/template.js`. It receives the full violation + employee record. Logo is loaded from disk at startup and embedded as base64.
- Puppeteer launches with `--no-sandbox --disable-setuid-sandbox` (required for Docker; safe in this deployment context).
- Acknowledgment rendering: if `acknowledged_by` is set, show name + date in signature block. If not, render blank wet-ink signature lines.
- After any schema change that adds user-visible fields, update the template to include the new field where appropriate.
---
## Development Workflow
### Local Development (without Docker)
```bash
# Terminal 1 — backend
npm install
node server.js # Serves API on :3001 and client/dist statically
# Terminal 2 — frontend (hot reload)
cd client
npm install
npm run dev # Vite dev server on :5173 (proxy to :3001 configured in vite.config.js)
```
### Build & Deploy
```bash
# Build Docker image (compiles React inside container)
docker build -t cpas .
# Run (local)
docker run -d --name cpas -p 3001:3001 -v cpas-data:/data cpas
# Unraid: build → save → transfer → load → run with --pids-limit 2048
# See the "Deploying on Unraid" section of README.md for full instructions
```
**Unraid PID limit is critical.** Chromium spawns many child processes for PDF generation. Always include `--pids-limit 2048` on Unraid containers or PDF generation will fail silently.
### Health Check
`GET /api/health` returns `{ status: 'ok', timestamp, version }`. The `version` field is populated by the Dockerfile at build time from git commit SHA. In local dev it returns `{ sha: 'dev' }` — this is expected.
---
## Forward-Thinking Development Guidelines
### Adding New Features
- **Score-affecting logic belongs in `lib/rolloff.js`**, not scattered across route handlers or duplicated in SQL. `computeStanding()` is the single source of truth for point totals. If you need a new score variant (e.g., 30-day window, category-filtered), add it as an option/variant of the roll-off model — don't sum points ad hoc in a route.
- **New violation fields**: Add to `schema.sql` for fresh installs AND to the migration block in `database.js` for existing databases. Both are required.
- **Reporting features**: Future aggregate queries should call `computeStanding()` (or join against `audit_log`) rather than re-implementing point logic. Structure new API endpoints under `/api/reports/` namespace, registered after the auth guard.
- **Notifications/alerts**: Any future alerting feature (email, Slack) should read from `audit_log` or call `computeStanding()` — do not add side effects directly into violation insert routes.
- **Auth is already implemented** (see the Authentication section). It is global middleware (`app.use('/api', auth.requireAuth)`) applied before all `/api` routes, exactly as this guidance recommends — do not add per-route auth checks. When wiring identity into records, pass the signed-in user into `performed_by`/`changed_by` fields on audit and amendment records.
- **Multi-tenant / multi-site**: The schema is single-tenant. If site isolation is ever needed, add a `site_id` foreign key to `employees` and `violations` as a migration column, then scope all queries with a `WHERE site_id = ?` clause.
### What NOT to Do
- Do not compute active CPAS scores by summing violation points client-side. Always fetch the server-computed standing (`/api/employees/:id/score`, `/api/dashboard`, etc.), which runs the clean-cycle model in `lib/rolloff.js`. A naive client-side sum ignores roll-off and will be wrong.
- Do not modify `prior_active_points` after a violation is inserted, EXCEPT via one of the two sanctioned paths: automatic recompute on a back-dated insert (`recomputeSnapshotsAfter()`), or manual admin backfill (`recomputeAllSnapshotsForEmployee()` behind `POST /api/employees/:id/recompute-snapshots` and the **↻ Backfill Snapshots** UI button). Both are audit-logged as `violation_snapshots_recomputed`. 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.
- Do not use `alert()`, `console.log` for user messages, or `document.querySelector` inside React components.
---
## Documentation Standards
### Code Comments
- Comment **why**, not **what**. If the reason for a decision is not obvious from the code, explain it.
- Use the existing banner style for section groupings in `server.js`:
```js
// ── Section Name ─────────────────────────────────────────────────────────────
```
- Mark non-obvious schema columns with inline SQL comments (see `schema.sql` for examples).
- When adding a migration block, include a comment naming the feature it enables.
### In-App Documentation
The `ReadmeModal.jsx` component renders an admin reference panel accessible via the `? Docs` button. When adding a significant new feature:
- Add it to the feature map section of the docs modal.
- Update the tier system table if thresholds change.
- Move completed roadmap items from the "Proposed" section to the "Completed" section.
### README
Update `README.md` when:
- A new environment variable is introduced.
- The Docker run command changes (new volume, port, or flag).
- A new top-level feature is added that HR administrators need to know about.
Do not add implementation details to README — that belongs in code comments or AGENTS.md.
---
## Constraints & Non-Goals
- **Authentication is intentionally minimal.** A login gate, scrypt-hashed passwords, bearer-token sessions, and an admin/user role split exist (see the Authentication section) — but the app still runs on a trusted LAN. Do not layer on OAuth, SSO, password-reset email flows, or finer-grained permission tiers without explicit direction from the project owner. There is no CSRF/rate-limiting hardening by design; keep it deployed behind the LAN.
- **No external dependencies beyond what's in `package.json`.** Avoid introducing new npm packages unless they solve a clearly scoped problem. Prefer using existing stack capabilities.
- **No client-side routing library.** Navigation between Violation Form, Dashboard, and modals is handled via `App.jsx` state (`view` prop). Do not introduce React Router unless the navigation model meaningfully grows beyond 34 views.
- **No test suite currently.** If adding tests, use Vitest for frontend and a lightweight assertion library for backend routes. Do not add a full testing framework without discussion.
- **SQLite only.** Do not introduce Postgres, Redis, or other datastores. The single-file DB on a Docker volume is the correct solution for this scale.