backfill button and usage
Build and Push Docker Image / build (push) Successful in 16s

This commit is contained in:
2026-05-19 09:29:41 -05:00
parent 6ddc09aa71
commit 2d4920bd15
7 changed files with 171 additions and 5 deletions
+5 -1
View File
@@ -5,7 +5,11 @@
"Bash(env)", "Bash(env)",
"Bash(findstr /I OBSIDIAN)", "Bash(findstr /I OBSIDIAN)",
"Bash(set)", "Bash(set)",
"Bash(xargs grep -l -i \"obsidian\")" "Bash(xargs grep -l -i \"obsidian\")",
"Bash(xargs -I {} git log --oneline -1 {})",
"Bash(ls -la data)",
"Bash(ls *.db)",
"Bash(find . -name \"cpas.db\" -not -path \"*/node_modules/*\")"
] ]
} }
} }
+8 -2
View File
@@ -92,7 +92,13 @@ 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. 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. **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 ### Audit Log
@@ -249,7 +255,7 @@ docker run -d --name cpas -p 3001:3001 -v cpas-data:/data cpas
### What NOT to Do ### 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 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, 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 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 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 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 add a build step beyond `vite build`. The backend is plain CommonJS `require()`; do not transpile it.
+1 -1
View File
@@ -29,7 +29,7 @@ It manages **employee violations against the CPAS rubric** on a rolling 90-day w
- **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. - **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. - **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. - **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"), a per-violation 90-day **point expiration timeline** with projected tier drops, and a **↻ Backfill Snapshots** repair button that rebuilds the `prior_active_points` snapshot on every violation for that employee (use after a back-date if older PDFs show stale prior-point totals; audit-logged with reason `manual_backfill`).
- **Violation Amendment** — point value / type / incident date are immutable; non-scoring fields (location, witness, narrative, acknowledgment) are amendable with a field-level diff trail. - **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. - **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. - **Toast notification system** — global success/error/warning/info, auto-dismiss with progress bar.
+41
View File
@@ -158,6 +158,7 @@ Useful for showing the app to stakeholders without exposing live employee data.
- PDF download for any historical violation record - PDF download for any historical violation record
- **Notes & Flags** — free-text notes (e.g. "on PIP", "union member") with quick-add tag buttons; visible in the profile modal without affecting scoring - **Notes & Flags** — free-text notes (e.g. "on PIP", "union member") with quick-add tag buttons; visible in the profile modal without affecting scoring
- **Point Expiration Timeline** — shows when each active violation rolls off the 90-day window, with a progress bar, days-remaining countdown, and projected tier-drop indicators - **Point Expiration Timeline** — shows when each active violation rolls off the 90-day window, with a progress bar, days-remaining countdown, and projected tier-drop indicators
- **↻ Backfill Snapshots** button (next to the Active Violations header) — manually rebuilds the `prior_active_points` snapshot on every violation for this employee. Use after back-dating a violation under older code, or any time a regenerated PDF shows stale prior-point totals. Audit-logged as `violation_snapshots_recomputed` with reason `manual_backfill`. See [Backfilling Prior-Points Snapshots](#backfilling-prior-points-snapshots) below.
- **Toast notifications** for all actions: negate, restore, delete, amend, PDF download, employee edit - **Toast notifications** for all actions: negate, restore, delete, amend, PDF download, employee edit
### Audit Log ### Audit Log
@@ -208,6 +209,45 @@ Scores are computed over a **rolling 90-day window** (negated violations exclude
- Filename: `CPAS_<EmployeeName>_<IncidentDate>.pdf` - Filename: `CPAS_<EmployeeName>_<IncidentDate>.pdf`
- PDF captures prior active points **at the time of the incident** (snapshot stored on insert) - PDF captures prior active points **at the time of the incident** (snapshot stored on insert)
- **Acknowledgment rendering**: if the violation has an `acknowledged_by` value, the employee signature block on the PDF shows the recorded name and date with an "Acknowledged" badge; otherwise, blank signature lines are rendered for wet-ink signing - **Acknowledgment rendering**: if the violation has an `acknowledged_by` value, the employee signature block on the PDF shows the recorded name and date with an "Acknowledged" badge; otherwise, blank signature lines are rendered for wet-ink signing
- **Back-dated inserts** auto-refresh the snapshot on downstream violations whose 90-day prior window now includes the new earlier event (handled inside the insert transaction by `recomputeSnapshotsAfter()`). If a back-date happened under older code that lacked this auto-refresh, use the **↻ Backfill Snapshots** button in the Employee Profile Modal — see [Backfilling Prior-Points Snapshots](#backfilling-prior-points-snapshots).
---
## Backfilling Prior-Points Snapshots
Each violation stores a `prior_active_points` snapshot at insert time so its PDF always reflects the score *as it was on the incident date* (and stays stable through later negate/restore actions). Normally you never touch this column.
There is one situation where the snapshot can drift from current truth: a violation was back-dated *before* `recomputeSnapshotsAfter()` shipped (commit `e2c352d`), so the auto-refresh never ran on the violations that now sit inside its 90-day window. Symptom: re-downloading the PDF for the newer violation shows "Prior Active Points: 0" even though an earlier active violation clearly exists in the timeline.
**To fix:**
1. Open the affected employee's profile modal.
2. Click **↻ Backfill Snapshots** next to the **Active Violations** header.
3. Confirm the prompt. A toast reports `Updated X of Y snapshot(s)` or `Snapshots already up to date`.
4. Re-download the PDFs — they now reflect the corrected prior totals.
**What it does, exactly:**
- Iterates every violation belonging to that employee (active *and* negated).
- Recomputes each row's `prior_active_points` using the current set of non-negated violations in the 90 days before its `incident_date`.
- Writes only the rows that actually changed and reports the diff.
- Runs inside a single transaction.
- Writes one `violation_snapshots_recomputed` entry to the audit log with `reason: "manual_backfill"` and the per-row before/after values.
**When *not* to use it:**
- After a negate, restore, amend, or hard delete in normal workflow. The auto-managed snapshot is correct in those cases by design (PDFs are intentionally stable through negate/restore).
- As a routine maintenance step. It's a targeted repair tool, not a recurring task. If you find yourself reaching for it after normal back-dated inserts, file a bug — the auto-recompute should already be handling those.
**API endpoint:** `POST /api/employees/:id/recompute-snapshots`
Response shape:
```json
{ "success": true, "scanned": 2, "updated": 1, "changes": [
{ "id": 47, "incident_date": "2026-04-02", "old": 0, "new": 3 }
]}
```
--- ---
@@ -222,6 +262,7 @@ Scores are computed over a **rolling 90-day window** (negated violations exclude
| PATCH | `/api/employees/:id` | Edit name, department, supervisor, or notes | | PATCH | `/api/employees/:id` | Edit name, department, supervisor, or notes |
| PATCH | `/api/employees/:id/notes` | Save employee notes only (shorthand) | | PATCH | `/api/employees/:id/notes` | Save employee notes only (shorthand) |
| POST | `/api/employees/:id/merge` | Merge duplicate employee; reassigns all violations | | POST | `/api/employees/:id/merge` | Merge duplicate employee; reassigns all violations |
| POST | `/api/employees/:id/recompute-snapshots` | Manual backfill — rebuild `prior_active_points` on every violation for this employee. See [Backfilling Prior-Points Snapshots](#backfilling-prior-points-snapshots) |
| GET | `/api/employees/:id/score` | Get active CPAS score for employee | | GET | `/api/employees/:id/score` | Get active CPAS score for employee |
| GET | `/api/employees/:id/expiration` | Active violation roll-off timeline with days remaining | | GET | `/api/employees/:id/expiration` | Active violation roll-off timeline with days remaining |
| GET | `/api/employees/:id/violation-counts` | 90-day non-negated counts grouped by violation type | | GET | `/api/employees/:id/violation-counts` | 90-day non-negated counts grouped by violation type |
+37 -1
View File
@@ -86,6 +86,11 @@ const s = {
fontSize: '9px', fontWeight: 700, background: '#0e2a2a', color: '#4db6ac', fontSize: '9px', fontWeight: 700, background: '#0e2a2a', color: '#4db6ac',
border: '1px solid #1a4a4a', verticalAlign: 'middle', border: '1px solid #1a4a4a', verticalAlign: 'middle',
}, },
backfillBtn: {
background: 'none', border: '1px solid #d4af37', color: '#ffd666',
borderRadius: '4px', padding: '4px 10px', fontSize: '11px',
cursor: 'pointer', fontWeight: 600,
},
}; };
export default function EmployeeModal({ employeeId, onClose }) { export default function EmployeeModal({ employeeId, onClose }) {
@@ -156,6 +161,26 @@ export default function EmployeeModal({ employeeId, onClose }) {
} }
}; };
const handleRecomputeSnapshots = async () => {
if (!window.confirm(
'Rebuild the "Prior Active Points" snapshot on every violation for this employee?\n\n' +
'Use this after back-dating a violation if older PDFs no longer reflect the correct prior-points total. ' +
'Existing PDFs will regenerate with up-to-date numbers.'
)) return;
try {
const r = await axios.post(`/api/employees/${employeeId}/recompute-snapshots`);
const { scanned, updated } = r.data;
if (updated === 0) {
toast.success(`Snapshots already up to date (${scanned} checked).`);
} else {
toast.success(`Updated ${updated} of ${scanned} snapshot${updated === 1 ? '' : 's'}.`);
}
load();
} catch (err) {
toast.error('Backfill failed: ' + (err.response?.data?.error || err.message));
}
};
const handleNegate = async ({ resolution_type, details, resolved_by }) => { const handleNegate = async ({ resolution_type, details, resolved_by }) => {
try { try {
await axios.patch(`/api/violations/${negating.id}/negate`, { resolution_type, details, resolved_by }); await axios.patch(`/api/violations/${negating.id}/negate`, { resolution_type, details, resolved_by });
@@ -251,7 +276,18 @@ export default function EmployeeModal({ employeeId, onClose }) {
)} )}
{/* ── Active Violations ── */} {/* ── Active Violations ── */}
<div style={s.sectionHd}>Active Violations</div> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '24px', marginBottom: '10px' }}>
<div style={{ ...s.sectionHd, marginTop: 0, marginBottom: 0 }}>Active Violations</div>
{violations.length > 0 && (
<button
style={s.backfillBtn}
onClick={handleRecomputeSnapshots}
title="Rebuild prior-points snapshot on each violation. Use after a back-dated insert if older PDFs show the wrong Prior Active Points."
>
Backfill Snapshots
</button>
)}
</div>
{active.length === 0 ? ( {active.length === 0 ? (
<div style={{ color: '#777990', fontStyle: 'italic', fontSize: '12px' }}> <div style={{ color: '#777990', fontStyle: 'italic', fontSize: '12px' }}>
No active violations on record. No active violations on record.
+27
View File
@@ -164,6 +164,31 @@ Update name, department, or supervisor. Changes are logged to the audit trail.
#### Merge Duplicate #### 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. 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.
#### Backfill Snapshots (repair tool)
Each violation stores a **prior-points snapshot** at submission time so its PDF always shows the score *as it was on the incident date*. Normally you never touch this — it's set on insert, refreshed automatically when a back-dated violation lands inside another violation's 90-day window, and otherwise locked. PDFs stay stable through negate/restore by design.
The **↻ Backfill Snapshots** button sits next to the **Active Violations** header in the profile modal. It rebuilds the snapshot on every violation for that employee using current data.
**When to use it:**
- After back-dating a violation, an older PDF still shows "Prior Active Points: 0" even though an earlier violation now clearly exists in the timeline.
- More generally: any time a regenerated PDF disagrees with what you see in the Point Expiration Timeline (the timeline is computed live; PDFs use the snapshot).
**When *not* to use it:**
- After a negate, restore, amend, or hard delete in normal workflow. The system intentionally keeps existing PDFs stable through those operations.
- As a routine maintenance step. If you keep needing it after ordinary back-dated inserts, that's a bug worth reporting — the auto-refresh should already be covering you.
**What clicking it does:**
1. Iterates every violation belonging to the employee (active and negated).
2. Recomputes each row's prior-points snapshot from the current set of non-negated violations in the 90 days before its incident date.
3. Writes only the rows that actually changed.
4. Records one entry in the audit log (action: \`violation_snapshots_recomputed\`, reason: \`manual_backfill\`) with the per-row before/after values.
A toast confirms the outcome — either *"Updated X of Y snapshots"* or *"Snapshots already up to date"*. Re-download any affected PDFs after running it; the new totals will appear immediately.
--- ---
### Audit Log ### Audit Log
@@ -217,6 +242,7 @@ Toasts auto-dismiss after a few seconds (errors persist longer). Each toast has
| Edit employee name / dept / supervisor | Yes | Logged to audit trail | | Edit employee name / dept / supervisor | Yes | Logged to audit trail |
| Merge duplicate employees | Yes | Irreversible | | Merge duplicate employees | Yes | Irreversible |
| Add / edit employee notes | Yes | Does not affect score | | Add / edit employee notes | Yes | Does not affect score |
| Recompute prior-points snapshot | Yes | Two paths only: auto (back-dated insert) or **↻ Backfill Snapshots** button. Never touched by negate, restore, amend, or hard delete |
--- ---
@@ -237,6 +263,7 @@ Toasts auto-dismiss after a few seconds (errors persist longer). Each toast has
- In-app admin guide (this panel) - In-app admin guide (this panel)
- Acknowledgment signature field — employee name + date on form and PDF - Acknowledgment signature field — employee name + date on form and PDF
- Toast notification system — global feedback for all user actions - Toast notification system — global feedback for all user actions
- Backfill Snapshots — per-employee repair tool that rebuilds the prior-points snapshot on every violation when older PDFs drift from current data
--- ---
+52
View File
@@ -287,6 +287,58 @@ function recomputeSnapshotsAfter(employeeId, incidentDate) {
return changes; return changes;
} }
// Helper: rebuild prior_active_points for every violation belonging to an
// employee, regardless of negate state. Used by the manual backfill button
// to repair snapshots after a back-dated insert that happened under older
// code (before recomputeSnapshotsAfter existed) or any case where the
// per-row snapshot has drifted from current data.
function recomputeAllSnapshotsForEmployee(employeeId) {
const rows = db.prepare(`
SELECT id, incident_date, prior_active_points
FROM violations
WHERE employee_id = ?
ORDER BY incident_date ASC
`).all(employeeId);
const updateStmt = db.prepare('UPDATE violations SET prior_active_points = ? WHERE id = ?');
const changes = [];
for (const v of rows) {
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 { scanned: rows.length, changes };
}
// POST /api/employees/:id/recompute-snapshots
// Manual backfill — rebuild prior_active_points for every violation on this
// employee. Use after a back-dated insert under older code left downstream
// PDFs showing stale "Prior Active Points".
app.post('/api/employees/:id/recompute-snapshots', (req, res) => {
const empId = parseInt(req.params.id);
const emp = db.prepare('SELECT id, name FROM employees WHERE id = ?').get(empId);
if (!emp) return res.status(404).json({ error: 'Employee not found' });
const result = db.transaction(() => recomputeAllSnapshotsForEmployee(empId))();
if (result.changes.length > 0) {
audit('violation_snapshots_recomputed', 'employee', empId, req.body?.performed_by, {
reason: 'manual_backfill',
scanned: result.scanned,
affected: result.changes,
});
}
res.json({
success: true,
scanned: result.scanned,
updated: result.changes.length,
changes: result.changes,
});
});
// POST new violation // POST new violation
app.post('/api/violations', (req, res) => { app.post('/api/violations', (req, res) => {
const { const {