90 day rolloff fix
Build and Push Docker Image / build (push) Successful in 16s

This commit is contained in:
2026-05-27 21:41:57 -05:00
parent 08401afd28
commit 6ce9788a6b
7 changed files with 249 additions and 116 deletions
+77 -57
View File
@@ -4,6 +4,7 @@ const path = require('path');
const db = require('./db/database');
const auth = require('./auth');
const generatePdf = require('./pdf/generator');
const { computeStanding } = require('./lib/rolloff');
const app = express();
const PORT = process.env.PORT || 3001;
@@ -33,6 +34,20 @@ function audit(action, entityType, entityId, performedBy, details) {
}
}
// ── CPAS standing helpers ────────────────────────────────────────────────────
// All point/roll-off math lives in lib/rolloff.js. These helpers just feed it
// the employee's non-negated violations and return the computed standing.
function loadViolations(employeeId) {
return db.prepare(
`SELECT id, points, incident_date, negated, violation_name, violation_type, category
FROM violations
WHERE employee_id = ? AND negated = 0`
).all(employeeId);
}
function standingFor(employeeId, asOf) {
return computeStanding(loadViolations(employeeId), asOf);
}
// ── 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 };
@@ -224,8 +239,8 @@ app.patch('/api/employees/:id/notes', (req, res) => {
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);
// Active points from the clean-cycle roll-off model
const standing = standingFor(empId);
// Total violations (all time) and negated count
const totals = db.prepare(`
@@ -238,50 +253,57 @@ app.get('/api/employees/:id/score', (req, res) => {
res.json({
employee_id: empId,
active_points: active ? active.active_points : 0,
violation_count: active ? active.violation_count : 0,
active_points: standing.activePoints,
violation_count: standing.violationCount,
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.
// ── Roll-off Timeline ────────────────────────────────────────────────────────
// GET /api/employees/:id/expiration — projected clean-cycle roll-off schedule.
// Points only retire after 90 consecutive days with no new violation; any new
// violation resets the clock. Returns the active total plus each future 5-point
// roll-off event (oldest points first) until the balance reaches zero.
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);
const standing = standingFor(req.params.id);
res.json({
active_points: standing.activePoints,
last_violation_date: standing.lastViolationDate,
next_rolloff_date: standing.nextRolloffDate,
schedule: standing.schedule,
});
});
// 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();
const employees = db.prepare(
'SELECT id, name, department, supervisor FROM employees'
).all();
// Pull every non-negated violation once, bucket by employee, then run the
// roll-off model per employee rather than issuing a query each.
const buckets = new Map();
for (const v of db.prepare(
`SELECT id, employee_id, points, incident_date, negated
FROM violations WHERE negated = 0`
).all()) {
if (!buckets.has(v.employee_id)) buckets.set(v.employee_id, []);
buckets.get(v.employee_id).push(v);
}
const rows = employees.map(e => {
const standing = computeStanding(buckets.get(e.id) || []);
return {
...e,
active_points: standing.activePoints,
violation_count: standing.violationCount,
};
});
rows.sort((a, b) =>
b.active_points - a.active_points || a.name.localeCompare(b.name)
);
res.json(rows);
});
@@ -309,33 +331,32 @@ app.get('/api/violations/:id/amendments', (req, res) => {
res.json(rows);
});
// Helper: compute prior_active_points at time of insert
// Helper: compute prior_active_points at time of insert — the employee's active
// CPAS standing the instant BEFORE this incident, under the clean-cycle model.
// Uses only violations strictly earlier than this incident, evaluated as of the
// incident date.
function getPriorActivePoints(employeeId, incidentDate) {
const row = db.prepare(
`SELECT COALESCE(SUM(points),0) AS pts
const earlier = db.prepare(
`SELECT id, points, incident_date, negated
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;
WHERE employee_id = ? AND negated = 0 AND incident_date < ?`
).all(employeeId, incidentDate);
return computeStanding(earlier, incidentDate).activePoints;
}
// 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.
// Helper: after a back-dated insert, refresh snapshots on every later
// violation. Under the clean-cycle roll-off model an earlier incident raises
// the running total and shifts the roll-off clock for ALL subsequent
// violations, so their "Prior Active Points" snapshots can all change — not
// just those within 90 days. Snapshots remain 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);
`).all(employeeId, incidentDate);
const updateStmt = db.prepare('UPDATE violations SET prior_active_points = ? WHERE id = ?');
const changes = [];
@@ -732,13 +753,12 @@ app.get('/api/violations/:id/pdf', async (req, res) => {
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 standing = standingFor(violation.employee_id);
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,
active_points: violation.prior_active_points != null ? violation.prior_active_points : standing.activePoints,
violation_count: standing.violationCount,
};
const pdfBuffer = await generatePdf(violation, scoreForPdf);