This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user