const express = require('express'); const cors = require('cors'); const path = require('path'); const db = require('./db/database'); const generatePdf = require('./pdf/generator'); const app = express(); const PORT = process.env.PORT || 3001; app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, 'client', 'dist'))); // ── Audit helper ───────────────────────────────────────────────────────────── function audit(action, entityType, entityId, performedBy, details) { try { db.prepare(` INSERT INTO audit_log (action, entity_type, entity_id, performed_by, details) VALUES (?, ?, ?, ?, ?) `).run(action, entityType, entityId || null, performedBy || null, typeof details === 'object' ? JSON.stringify(details) : (details || null)); } catch (e) { console.error('[AUDIT]', e.message); } } // Health app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() })); // ── 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); }); app.post('/api/employees', (req, res) => { const { name, department, supervisor } = req.body; if (!name) return res.status(400).json({ error: 'name is required' }); const existing = db.prepare('SELECT * FROM employees WHERE LOWER(name) = LOWER(?)').get(name); if (existing) { if (department || supervisor) { db.prepare('UPDATE employees SET department = COALESCE(?, department), supervisor = COALESCE(?, supervisor) WHERE id = ?') .run(department || null, supervisor || null, existing.id); } return res.json({ ...existing, department, supervisor }); } const result = db.prepare('INSERT INTO employees (name, department, supervisor) VALUES (?, ?, ?)') .run(name, department || null, supervisor || null); audit('employee_created', 'employee', result.lastInsertRowid, null, { name }); res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor }); }); // ── 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); const emp = db.prepare('SELECT * FROM employees WHERE id = ?').get(id); if (!emp) return res.status(404).json({ error: 'Employee not found' }); const { name, department, supervisor, notes, performed_by } = req.body; // Prevent name collision with a different employee if (name && name.trim() !== emp.name) { const clash = db.prepare('SELECT id FROM employees WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), id); if (clash) return res.status(409).json({ error: 'An employee with that name already exists', conflictId: clash.id }); } const newName = (name || emp.name).trim(); const newDept = department !== undefined ? (department || null) : emp.department; const newSupervisor = supervisor !== undefined ? (supervisor || null) : emp.supervisor; const newNotes = notes !== undefined ? (notes || null) : emp.notes; db.prepare('UPDATE employees SET name = ?, department = ?, supervisor = ?, notes = ? WHERE id = ?') .run(newName, newDept, newSupervisor, newNotes, id); audit('employee_edited', 'employee', id, performed_by, { before: { name: emp.name, department: emp.department, supervisor: emp.supervisor, notes: emp.notes }, after: { name: newName, department: newDept, supervisor: newSupervisor, notes: newNotes }, }); res.json({ id, name: newName, department: newDept, supervisor: newSupervisor, notes: newNotes }); }); // ── 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); const { source_id, performed_by } = req.body; if (!source_id) return res.status(400).json({ error: 'source_id is required' }); const target = db.prepare('SELECT * FROM employees WHERE id = ?').get(targetId); const source = db.prepare('SELECT * FROM employees WHERE id = ?').get(source_id); if (!target) return res.status(404).json({ error: 'Target employee not found' }); if (!source) return res.status(404).json({ error: 'Source employee not found' }); 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; }); const violationsMoved = mergeTransaction(); audit('employee_merged', 'employee', targetId, performed_by, { source: { id: source.id, name: source.name }, target: { id: target.id, name: target.name }, violations_reassigned: violationsMoved, }); res.json({ success: true, violations_reassigned: violationsMoved }); }); // ── 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); const emp = db.prepare('SELECT * FROM employees WHERE id = ?').get(id); if (!emp) return res.status(404).json({ error: 'Employee not found' }); const { notes, performed_by } = req.body; const newNotes = notes !== undefined ? (notes || null) : emp.notes; db.prepare('UPDATE employees SET notes = ? WHERE id = ?').run(newNotes, id); audit('employee_notes_updated', 'employee', id, performed_by, { notes: newNotes }); res.json({ id, notes: newNotes }); }); // Employee score (current snapshot) app.get('/api/employees/:id/score', (req, res) => { const row = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(req.params.id); res.json(row || { employee_id: req.params.id, active_points: 0, violation_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. 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); }); // 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(); res.json(rows); }); // Violation history (per employee) with resolutions + amendment count app.get('/api/violations/employee/:id', (req, res) => { const limit = parseInt(req.query.limit) || 50; const rows = db.prepare(` SELECT v.*, r.resolution_type, r.details AS resolution_details, r.resolved_by, r.created_at AS resolved_at, (SELECT COUNT(*) FROM violation_amendments a WHERE a.violation_id = v.id) AS amendment_count FROM violations v LEFT JOIN violation_resolutions r ON r.violation_id = v.id WHERE v.employee_id = ? ORDER BY v.incident_date DESC, v.created_at DESC LIMIT ? `).all(req.params.id, limit); res.json(rows); }); // ── 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 `).all(req.params.id); res.json(rows); }); // Helper: compute prior_active_points at time of insert function getPriorActivePoints(employeeId, incidentDate) { const row = db.prepare( `SELECT COALESCE(SUM(points),0) AS pts 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; } // POST new violation 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 } = req.body; if (!employee_id || !violation_type || !points || !incident_date) { return res.status(400).json({ error: 'Missing required fields' }); } const ptsInt = parseInt(points); const priorPts = getPriorActivePoints(employee_id, incident_date); const result = db.prepare(` INSERT INTO violations ( employee_id, violation_type, violation_name, category, points, incident_date, incident_time, location, details, submitted_by, witness_name, prior_active_points ) 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, priorPts ); audit('violation_created', 'violation', result.lastInsertRowid, submitted_by, { employee_id, violation_type, points: ptsInt, incident_date, }); res.status(201).json({ id: result.lastInsertRowid }); }); // ── 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']; app.patch('/api/violations/:id/amend', (req, res) => { const id = parseInt(req.params.id); const { changed_by, ...updates } = req.body; const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id); if (!violation) return res.status(404).json({ error: 'Violation not found' }); if (violation.negated) return res.status(400).json({ error: 'Cannot amend a negated violation' }); // Only allow whitelisted fields to be amended const allowed = Object.fromEntries( Object.entries(updates).filter(([k]) => AMENDABLE_FIELDS.includes(k)) ); if (Object.keys(allowed).length === 0) { return res.status(400).json({ error: 'No amendable fields provided', amendable: AMENDABLE_FIELDS }); } const amendTransaction = db.transaction(() => { // Build UPDATE const setClauses = Object.keys(allowed).map(k => `${k} = ?`).join(', '); const values = [...Object.values(allowed), id]; db.prepare(`UPDATE violations SET ${setClauses} WHERE id = ?`).run(...values); // Insert an amendment record per changed field const insertAmendment = db.prepare(` INSERT INTO violation_amendments (violation_id, changed_by, field_name, old_value, new_value) VALUES (?, ?, ?, ?, ?) `); for (const [field, newVal] of Object.entries(allowed)) { const oldVal = violation[field]; if (String(oldVal) !== String(newVal)) { insertAmendment.run(id, changed_by || null, field, oldVal ?? null, newVal ?? null); } } }); amendTransaction(); audit('violation_amended', 'violation', id, changed_by, { fields: Object.keys(allowed) }); const updated = db.prepare('SELECT * FROM violations WHERE id = ?').get(id); res.json(updated); }); // ── Negate a violation ──────────────────────────────────────────────────────── app.patch('/api/violations/:id/negate', (req, res) => { const { resolution_type, details, resolved_by } = req.body; const id = req.params.id; 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 negated = 1 WHERE id = ?').run(id); const existing = db.prepare('SELECT id FROM violation_resolutions WHERE violation_id = ?').get(id); if (existing) { db.prepare(` UPDATE violation_resolutions SET resolution_type = ?, details = ?, resolved_by = ?, created_at = datetime('now') WHERE violation_id = ? `).run(resolution_type || 'Resolved', details || null, resolved_by || null, id); } else { db.prepare(` INSERT INTO violation_resolutions (violation_id, resolution_type, details, resolved_by) VALUES (?, ?, ?, ?) `).run(id, resolution_type || 'Resolved', details || null, resolved_by || null); } audit('violation_negated', 'violation', id, resolved_by, { resolution_type }); res.json({ success: true }); }); // ── Restore a negated violation ─────────────────────────────────────────────── app.patch('/api/violations/:id/restore', (req, res) => { const id = req.params.id; 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 negated = 0 WHERE id = ?').run(id); db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id); audit('violation_restored', 'violation', id, req.body?.performed_by, {}); res.json({ success: true }); }); // ── Hard delete a violation ─────────────────────────────────────────────────── app.delete('/api/violations/:id', (req, res) => { const id = req.params.id; const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id); if (!violation) return res.status(404).json({ error: 'Violation not found' }); db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id); db.prepare('DELETE FROM violations WHERE id = ?').run(id); audit('violation_deleted', 'violation', id, req.body?.performed_by, { violation_type: violation.violation_type, employee_id: violation.employee_id, }); res.json({ success: true }); }); // ── 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; const type = req.query.entity_type; const id = req.query.entity_id; let sql = 'SELECT * FROM audit_log'; const args = []; const where = []; if (type) { where.push('entity_type = ?'); args.push(type); } if (id) { where.push('entity_id = ?'); args.push(id); } if (where.length) sql += ' WHERE ' + where.join(' AND '); sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; args.push(limit, offset); res.json(db.prepare(sql).all(...args)); }); // ── PDF endpoint ────────────────────────────────────────────────────────────── app.get('/api/violations/:id/pdf', async (req, res) => { try { const violation = db.prepare(` SELECT v.*, e.name as employee_name, e.department, e.supervisor FROM violations v JOIN employees e ON e.id = v.employee_id WHERE v.id = ? `).get(req.params.id); 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 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, }; const pdfBuffer = await generatePdf(violation, scoreForPdf); const safeName = violation.employee_name.replace(/[^a-z0-9]/gi, '_'); res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': `attachment; filename="CPAS_${safeName}_${violation.incident_date}.pdf"`, 'Content-Length': pdfBuffer.length, }); res.end(pdfBuffer); } catch (err) { console.error('[PDF]', err); res.status(500).json({ error: 'PDF generation failed', detail: err.message }); } }); // SPA fallback app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'client', 'dist', 'index.html'))); app.listen(PORT, '0.0.0.0', () => console.log(`[CPAS] Server running on port ${PORT}`));