From 200bef31f5321133e6da16550dc94974c1c50a78 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 19:31:55 -0600 Subject: [PATCH] feat: add acknowledge endpoint, supervisor dashboard filter, analytics endpoints --- server.js | 140 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 116 insertions(+), 24 deletions(-) diff --git a/server.js b/server.js index 01216e2..c67e4a7 100755 --- a/server.js +++ b/server.js @@ -11,7 +11,7 @@ app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, 'client', 'dist'))); -// ── Audit helper ───────────────────────────────────────────────────────────── +// ── Audit helper ──────────────────────────────────────────────────────────── function audit(action, entityType, entityId, performedBy, details) { try { db.prepare(` @@ -27,7 +27,7 @@ function audit(action, entityType, entityId, performedBy, details) { // Health app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() })); -// ── Employees ───────────────────────────────────────────────────────────────── +// ── 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); @@ -50,7 +50,7 @@ app.post('/api/employees', (req, res) => { res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor }); }); -// ── Employee Edit ───────────────────────────────────────────────────────────── +// ── 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); @@ -81,7 +81,7 @@ app.patch('/api/employees/:id', (req, res) => { res.json({ id, name: newName, department: newDept, supervisor: newSupervisor, notes: newNotes }); }); -// ── Employee Merge ──────────────────────────────────────────────────────────── +// ── 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); @@ -95,9 +95,7 @@ app.post('/api/employees/:id/merge', (req, res) => { 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; }); @@ -113,7 +111,7 @@ app.post('/api/employees/:id/merge', (req, res) => { res.json({ success: true, violations_reassigned: violationsMoved }); }); -// ── Employee notes (PATCH shorthand) ───────────────────────────────────────── +// ── 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); @@ -134,9 +132,8 @@ app.get('/api/employees/:id/score', (req, res) => { res.json(row || { employee_id: req.params.id, active_points: 0, violation_count: 0 }); }); -// ── Expiration Timeline ─────────────────────────────────────────────────────── +// ── 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 @@ -146,12 +143,12 @@ app.get('/api/employees/:id/expiration', (req, res) => { v.category, v.points, v.incident_date, - DATE(v.incident_date, '+90 days') AS expires_on, + 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 + ) AS days_remaining FROM violations v WHERE v.employee_id = ? AND v.negated = 0 @@ -161,15 +158,88 @@ app.get('/api/employees/:id/expiration', (req, res) => { res.json(rows); }); -// Dashboard +// ── Dashboard ──────────────────────────────────────────────────────────────── +// Optional ?supervisor= filter for scoped views app.get('/api/dashboard', (req, res) => { - const rows = db.prepare(` + const supervisor = req.query.supervisor || null; + let sql = ` 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 + `; + const args = []; + if (supervisor) { + sql += ` WHERE LOWER(e.supervisor) = LOWER(?)`; + args.push(supervisor); + } + sql += ` ORDER BY active_points DESC, e.name ASC`; + res.json(db.prepare(sql).all(...args)); +}); + +// ── Analytics: Violation Trends ───────────────────────────────────────────── +// GET /api/analytics/trends — weekly violation counts (last 52 weeks) +// Optional ?supervisor=, ?department= +app.get('/api/analytics/trends', (req, res) => { + const { supervisor, department } = req.query; + const where = ['v.negated = 0']; + const args = []; + if (supervisor) { where.push("LOWER(e.supervisor) = LOWER(?)"); args.push(supervisor); } + if (department) { where.push("LOWER(e.department) = LOWER(?)"); args.push(department); } + + const rows = db.prepare(` + SELECT + strftime('%Y-%W', v.incident_date) AS week, + COUNT(*) AS violation_count, + SUM(v.points) AS total_points + FROM violations v + JOIN employees e ON e.id = v.employee_id + WHERE ${where.join(' AND ')} + AND v.incident_date >= DATE('now', '-365 days') + GROUP BY week + ORDER BY week ASC + `).all(...args); + res.json(rows); +}); + +// ── Analytics: Department Summary ─────────────────────────────────────────── +// GET /api/analytics/departments — aggregated stats per department +app.get('/api/analytics/departments', (req, res) => { + const rows = db.prepare(` + SELECT + COALESCE(e.department, 'Unassigned') AS department, + COUNT(DISTINCT e.id) AS employee_count, + COALESCE(SUM(s.active_points), 0) AS total_active_points, + COALESCE(SUM(s.violation_count), 0) AS total_violations, + ROUND( + CAST(COALESCE(SUM(s.active_points), 0) AS FLOAT) / + NULLIF(COUNT(DISTINCT e.id), 0) + , 1) AS avg_points_per_employee + FROM employees e + LEFT JOIN active_cpas_scores s ON s.employee_id = e.id + GROUP BY COALESCE(e.department, 'Unassigned') + ORDER BY total_active_points DESC + `).all(); + res.json(rows); +}); + +// ── Analytics: Recurrence Heatmap ─────────────────────────────────────────── +// GET /api/analytics/recurrence — violation type counts per employee (90-day active only) +app.get('/api/analytics/recurrence', (req, res) => { + const rows = db.prepare(` + SELECT + e.id AS employee_id, + e.name AS employee_name, + v.violation_type, + v.violation_name, + COUNT(*) AS count + FROM violations v + JOIN employees e ON e.id = v.employee_id + WHERE v.negated = 0 + AND v.incident_date >= DATE('now', '-90 days') + GROUP BY e.id, v.violation_type + ORDER BY count DESC `).all(); res.json(rows); }); @@ -190,7 +260,7 @@ app.get('/api/violations/employee/:id', (req, res) => { res.json(rows); }); -// ── Violation amendment history ─────────────────────────────────────────────── +// ── 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 @@ -216,7 +286,8 @@ 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 + details, submitted_by, witness_name, + acknowledged_by, acknowledged_at, } = req.body; if (!employee_id || !violation_type || !points || !incident_date) { @@ -231,13 +302,15 @@ app.post('/api/violations', (req, res) => { employee_id, violation_type, violation_name, category, points, incident_date, incident_time, location, details, submitted_by, witness_name, + acknowledged_by, acknowledged_at, prior_active_points - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) 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, + acknowledged_by || null, acknowledged_at || null, priorPts ); @@ -248,9 +321,9 @@ app.post('/api/violations', (req, res) => { res.status(201).json({ id: result.lastInsertRowid }); }); -// ── Violation Amendment (edit) ──────────────────────────────────────────────── +// ── 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']; +const AMENDABLE_FIELDS = ['incident_time', 'location', 'details', 'submitted_by', 'witness_name', 'acknowledged_by', 'acknowledged_at']; app.patch('/api/violations/:id/amend', (req, res) => { const id = parseInt(req.params.id); @@ -295,7 +368,26 @@ app.patch('/api/violations/:id/amend', (req, res) => { res.json(updated); }); -// ── Negate a violation ──────────────────────────────────────────────────────── +// ── Acknowledge a violation (dedicated endpoint) ───────────────────────────── +// PATCH /api/violations/:id/acknowledge — record employee receipt name + date +app.patch('/api/violations/:id/acknowledge', (req, res) => { + const id = parseInt(req.params.id); + const { acknowledged_by, acknowledged_at, performed_by } = req.body; + + 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 acknowledged_by = ?, acknowledged_at = ? WHERE id = ?') + .run(acknowledged_by || null, acknowledged_at || null, id); + + audit('violation_acknowledged', 'violation', id, performed_by || acknowledged_by, { + acknowledged_by, acknowledged_at, + }); + + res.json({ success: true, acknowledged_by, acknowledged_at }); +}); + +// ── Negate a violation ─────────────────────────────────────────────────────── app.patch('/api/violations/:id/negate', (req, res) => { const { resolution_type, details, resolved_by } = req.body; const id = req.params.id; @@ -323,7 +415,7 @@ app.patch('/api/violations/:id/negate', (req, res) => { res.json({ success: true }); }); -// ── Restore a negated violation ─────────────────────────────────────────────── +// ── Restore a negated violation ────────────────────────────────────────────── app.patch('/api/violations/:id/restore', (req, res) => { const id = req.params.id; @@ -337,7 +429,7 @@ app.patch('/api/violations/:id/restore', (req, res) => { res.json({ success: true }); }); -// ── Hard delete a violation ─────────────────────────────────────────────────── +// ── Hard delete a violation ────────────────────────────────────────────────── app.delete('/api/violations/:id', (req, res) => { const id = req.params.id; @@ -353,7 +445,7 @@ app.delete('/api/violations/:id', (req, res) => { res.json({ success: true }); }); -// ── Audit log ───────────────────────────────────────────────────────────────── +// ── 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; @@ -372,7 +464,7 @@ app.get('/api/audit', (req, res) => { res.json(db.prepare(sql).all(...args)); }); -// ── PDF endpoint ────────────────────────────────────────────────────────────── +// ── PDF endpoint ───────────────────────────────────────────────────────────── app.get('/api/violations/:id/pdf', async (req, res) => { try { const violation = db.prepare(`