Files
cpas/server.js

600 lines
26 KiB
JavaScript
Raw Normal View History

2026-03-06 12:53:40 -06:00
const express = require('express');
const cors = require('cors');
const path = require('path');
const db = require('./db/database');
const generatePdf = require('./pdf/generator');
2026-03-06 11:33:32 -06:00
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')));
2026-03-11 16:33:38 -05:00
// TODO [CRITICAL #1]: No authentication on any route. Add an auth middleware
// (e.g. express-session + password, or JWT) before all /api/* routes.
// Anyone on the network can currently create, delete, or negate violations.
// ── Demo static route ─────────────────────────────────────────────────────────
// Serves the standalone stakeholder demo page at /demo/index.html
// Must be registered before the SPA catch-all below.
app.use('/demo', express.static(path.join(__dirname, 'demo')));
// ── 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);
}
}
// ── 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 };
try {
BUILD_VERSION = require('./client/dist/version.json');
} catch (_) { /* pre-build or local dev — stub values are fine */ }
2026-03-06 14:42:12 -06:00
// Health
app.get('/api/health', (req, res) => res.json({
status: 'ok',
timestamp: new Date().toISOString(),
version: BUILD_VERSION,
}));
2026-03-06 11:33:32 -06:00
// ── Employees ────────────────────────────────────────────────────────────────
2026-03-06 11:33:32 -06:00
app.get('/api/employees', (req, res) => {
const rows = db.prepare('SELECT id, name, department, supervisor, notes FROM employees ORDER BY name ASC').all();
2026-03-06 14:42:12 -06:00
res.json(rows);
2026-03-06 11:33:32 -06:00
});
2026-03-11 16:33:38 -05:00
// GET /api/employees/:id — single employee record
app.get('/api/employees/:id', (req, res) => {
const emp = db.prepare('SELECT id, name, department, supervisor, notes FROM employees WHERE id = ?').get(req.params.id);
if (!emp) return res.status(404).json({ error: 'Employee not found' });
res.json(emp);
});
2026-03-06 11:33:32 -06:00
app.post('/api/employees', (req, res) => {
2026-03-06 14:42:12 -06:00
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);
2026-03-06 11:33:32 -06:00
}
2026-03-11 16:33:38 -05:00
// TODO [MINOR #16]: Spreading `existing` then overwriting with possibly-undefined
// `department`/`supervisor` returns `undefined` for unset fields.
// Re-query after update or only spread defined values.
2026-03-06 14:42:12 -06:00
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 });
2026-03-06 14:42:12 -06:00
res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor });
2026-03-06 11:33:32 -06:00
});
// ── 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) — includes total violations + negated count
2026-03-06 12:53:40 -06:00
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);
// Total violations (all time) and negated count
const totals = db.prepare(`
SELECT
COUNT(*) AS total_violations,
COALESCE(SUM(negated), 0) AS negated_count
FROM violations
WHERE employee_id = ?
`).get(empId);
res.json({
employee_id: empId,
active_points: active ? active.active_points : 0,
violation_count: active ? active.violation_count : 0,
total_violations: totals ? totals.total_violations : 0,
negated_count: totals ? totals.negated_count : 0,
});
2026-03-06 12:53:40 -06:00
});
// ── 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);
});
2026-03-06 14:42:12 -06:00
// Dashboard
2026-03-06 12:53:40 -06:00
app.get('/api/dashboard', (req, res) => {
2026-03-06 14:42:12 -06:00
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);
2026-03-06 11:33:32 -06:00
});
// Violation history (per employee) with resolutions + amendment count
2026-03-06 12:53:40 -06:00
app.get('/api/violations/employee/:id', (req, res) => {
2026-03-06 14:42:12 -06:00
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
2026-03-06 14:42:12 -06:00
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);
2026-03-06 12:02:52 -06:00
});
// ── 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);
});
2026-03-06 17:59:48 -06:00
// Helper: compute prior_active_points at time of insert
2026-03-06 14:42:12 -06:00
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
2026-03-06 11:33:32 -06:00
app.post('/api/violations', (req, res) => {
2026-03-06 14:42:12 -06:00
const {
employee_id, violation_type, violation_name, category,
points, incident_date, incident_time, location,
details, submitted_by, witness_name,
acknowledged_by, acknowledged_date
2026-03-06 14:42:12 -06:00
} = 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,
acknowledged_by, acknowledged_date
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2026-03-06 14:42:12 -06:00
`).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,
acknowledged_by || null, acknowledged_date || null
2026-03-06 14:42:12 -06:00
);
audit('violation_created', 'violation', result.lastInsertRowid, submitted_by, {
employee_id, violation_type, points: ptsInt, incident_date,
});
2026-03-06 14:42:12 -06:00
res.status(201).json({ id: result.lastInsertRowid });
2026-03-06 11:33:32 -06:00
});
// ── 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', 'acknowledged_by', 'acknowledged_date'];
2026-03-11 16:33:38 -05:00
// Pre-build one prepared UPDATE statement per amendable field combination is not
// practical (2^n combos), so instead we validate columns against the static
// whitelist and build the clause only from known-safe names at startup.
// The whitelist itself is the guard; no user-supplied column name ever enters SQL.
const AMEND_UPDATE_STMTS = Object.fromEntries(
AMENDABLE_FIELDS.map(f => [
f,
db.prepare(`UPDATE violations SET ${f} = ? WHERE id = ?`)
])
);
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(() => {
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];
2026-03-11 16:33:38 -05:00
// Use the pre-built statement for this field — no runtime interpolation
AMEND_UPDATE_STMTS[field].run(newVal, id);
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 ───────────────────────────────────────────────────────
2026-03-06 17:59:48 -06:00
app.patch('/api/violations/:id/negate', (req, res) => {
const { resolution_type, details, resolved_by } = req.body;
const id = req.params.id;
2026-03-06 12:53:40 -06:00
2026-03-06 17:59:48 -06:00
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 });
2026-03-06 17:59:48 -06:00
res.json({ success: true });
});
// ── Restore a negated violation ──────────────────────────────────────────────
2026-03-06 17:59:48 -06:00
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, {});
2026-03-06 17:59:48 -06:00
res.json({ success: true });
});
// ── Hard delete a violation ──────────────────────────────────────────────────
2026-03-06 17:59:48 -06:00
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,
});
2026-03-06 17:59:48 -06:00
res.json({ success: true });
});
2026-03-11 16:33:38 -05:00
// ── Violation counts per employee ────────────────────────────────────────────
// GET /api/employees/:id/violation-counts
// Returns { violation_type: count } for the rolling 90-day window (non-negated).
app.get('/api/employees/:id/violation-counts', (req, res) => {
const rows = db.prepare(`
SELECT violation_type, COUNT(*) AS count
FROM violations
WHERE employee_id = ?
AND negated = 0
AND incident_date >= DATE('now', '-90 days')
GROUP BY violation_type
`).all(req.params.id);
const result = {};
for (const r of rows) result[r.violation_type] = r.count;
res.json(result);
});
// GET /api/employees/:id/violation-counts/alltime
// Returns { violation_type: { count, max_points_used } } across all time (non-negated).
app.get('/api/employees/:id/violation-counts/alltime', (req, res) => {
const rows = db.prepare(`
SELECT violation_type, COUNT(*) AS count, MAX(points) AS max_points_used
FROM violations
WHERE employee_id = ?
AND negated = 0
GROUP BY violation_type
`).all(req.params.id);
const result = {};
for (const r of rows) result[r.violation_type] = { count: r.count, max_points_used: r.max_points_used };
res.json(result);
});
// ── 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));
});
// ── Custom Violation Types ────────────────────────────────────────────────────
// Persisted violation type definitions stored in violation_types table.
// type_key is auto-generated (custom_<slug>) to avoid collisions with hardcoded keys.
app.get('/api/violation-types', (req, res) => {
const rows = db.prepare('SELECT * FROM violation_types ORDER BY category ASC, name ASC').all();
res.json(rows.map(r => ({ ...r, fields: JSON.parse(r.fields) })));
});
app.post('/api/violation-types', (req, res) => {
const { name, category, chapter, description, min_points, max_points, fields, created_by } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
const minPts = parseInt(min_points) || 1;
const maxPts = parseInt(max_points) || minPts;
if (maxPts < minPts) return res.status(400).json({ error: 'max_points must be >= min_points' });
// Generate a unique type_key from the name, prefixed with 'custom_'
const base = 'custom_' + name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
let typeKey = base;
let suffix = 2;
while (db.prepare('SELECT id FROM violation_types WHERE type_key = ?').get(typeKey)) {
typeKey = `${base}_${suffix++}`;
}
try {
const result = db.prepare(`
INSERT INTO violation_types (type_key, name, category, chapter, description, min_points, max_points, fields)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
typeKey,
name.trim(),
(category || 'Custom').trim(),
chapter || null,
description || null,
minPts,
maxPts,
JSON.stringify(fields && fields.length ? fields : ['description'])
);
const row = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(result.lastInsertRowid);
audit('violation_type_created', 'violation_type', result.lastInsertRowid, created_by || null, { name: row.name, category: row.category });
res.status(201).json({ ...row, fields: JSON.parse(row.fields) });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/violation-types/:id', (req, res) => {
const id = parseInt(req.params.id);
const row = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(id);
if (!row) return res.status(404).json({ error: 'Violation type not found' });
const { name, category, chapter, description, min_points, max_points, fields, updated_by } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
const minPts = parseInt(min_points) || 1;
const maxPts = parseInt(max_points) || minPts;
if (maxPts < minPts) return res.status(400).json({ error: 'max_points must be >= min_points' });
db.prepare(`
UPDATE violation_types
SET name=?, category=?, chapter=?, description=?, min_points=?, max_points=?, fields=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?
`).run(
name.trim(),
(category || 'Custom').trim(),
chapter || null,
description || null,
minPts,
maxPts,
JSON.stringify(fields && fields.length ? fields : ['description']),
id
);
const updated = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(id);
audit('violation_type_updated', 'violation_type', id, updated_by || null, { name: updated.name, category: updated.category });
res.json({ ...updated, fields: JSON.parse(updated.fields) });
});
app.delete('/api/violation-types/:id', (req, res) => {
const id = parseInt(req.params.id);
const row = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(id);
if (!row) return res.status(404).json({ error: 'Violation type not found' });
const usage = db.prepare('SELECT COUNT(*) as count FROM violations WHERE violation_type = ?').get(row.type_key);
if (usage.count > 0) {
return res.status(409).json({ error: `Cannot delete: ${usage.count} violation(s) reference this type. Negate those violations first.` });
}
db.prepare('DELETE FROM violation_types WHERE id = ?').run(id);
audit('violation_type_deleted', 'violation_type', id, null, { name: row.name, type_key: row.type_key });
res.json({ ok: true });
});
// ── PDF endpoint ─────────────────────────────────────────────────────────────
2026-03-06 14:42:12 -06:00
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);
2026-03-06 12:53:40 -06:00
if (!violation) return res.status(404).json({ error: 'Violation not found' });
2026-03-06 14:42:12 -06:00
const active = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?')
.get(violation.employee_id) || { active_points: 0, violation_count: 0 };
const scoreForPdf = {
2026-03-06 17:59:48 -06:00
employee_id: violation.employee_id,
active_points: violation.prior_active_points != null ? violation.prior_active_points : active.active_points,
2026-03-06 14:42:12 -06:00
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,
2026-03-06 14:42:12 -06:00
});
res.end(pdfBuffer);
} catch (err) {
console.error('[PDF]', err);
res.status(500).json({ error: 'PDF generation failed', detail: err.message });
}
2026-03-06 12:19:55 -06:00
});
2026-03-06 14:42:12 -06:00
// SPA fallback
2026-03-06 12:53:40 -06:00
app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'client', 'dist', 'index.html')));
2026-03-06 11:33:32 -06:00
2026-03-06 12:53:40 -06:00
app.listen(PORT, '0.0.0.0', () => console.log(`[CPAS] Server running on port ${PORT}`));