roadmap #23
177
server.js
177
server.js
@@ -11,10 +11,23 @@ app.use(cors());
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(path.join(__dirname, 'client', 'dist')));
|
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
|
// Health
|
||||||
app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }));
|
app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }));
|
||||||
|
|
||||||
// Employees
|
// ── Employees ─────────────────────────────────────────────────────────────────
|
||||||
app.get('/api/employees', (req, res) => {
|
app.get('/api/employees', (req, res) => {
|
||||||
const rows = db.prepare('SELECT id, name, department, supervisor FROM employees ORDER BY name ASC').all();
|
const rows = db.prepare('SELECT id, name, department, supervisor FROM employees ORDER BY name ASC').all();
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
@@ -33,9 +46,72 @@ app.post('/api/employees', (req, res) => {
|
|||||||
}
|
}
|
||||||
const result = db.prepare('INSERT INTO employees (name, department, supervisor) VALUES (?, ?, ?)')
|
const result = db.prepare('INSERT INTO employees (name, department, supervisor) VALUES (?, ?, ?)')
|
||||||
.run(name, department || null, supervisor || null);
|
.run(name, department || null, supervisor || null);
|
||||||
|
audit('employee_created', 'employee', result.lastInsertRowid, null, { name });
|
||||||
res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor });
|
res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Employee Edit ─────────────────────────────────────────────────────────────
|
||||||
|
// PATCH /api/employees/:id — update name, department, or supervisor
|
||||||
|
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, 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;
|
||||||
|
|
||||||
|
db.prepare('UPDATE employees SET name = ?, department = ?, supervisor = ? WHERE id = ?')
|
||||||
|
.run(newName, newDept, newSupervisor, id);
|
||||||
|
|
||||||
|
audit('employee_edited', 'employee', id, performed_by, {
|
||||||
|
before: { name: emp.name, department: emp.department, supervisor: emp.supervisor },
|
||||||
|
after: { name: newName, department: newDept, supervisor: newSupervisor },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ id, name: newName, department: newDept, supervisor: newSupervisor });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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 score (current snapshot)
|
// Employee score (current snapshot)
|
||||||
app.get('/api/employees/:id/score', (req, res) => {
|
app.get('/api/employees/:id/score', (req, res) => {
|
||||||
const row = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(req.params.id);
|
const row = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(req.params.id);
|
||||||
@@ -55,12 +131,13 @@ app.get('/api/dashboard', (req, res) => {
|
|||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Violation history (per employee) with resolutions
|
// Violation history (per employee) with resolutions + amendment count
|
||||||
app.get('/api/violations/employee/:id', (req, res) => {
|
app.get('/api/violations/employee/:id', (req, res) => {
|
||||||
const limit = parseInt(req.query.limit) || 50;
|
const limit = parseInt(req.query.limit) || 50;
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT v.*, r.resolution_type, r.details AS resolution_details,
|
SELECT v.*, r.resolution_type, r.details AS resolution_details,
|
||||||
r.resolved_by, r.created_at AS resolved_at
|
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
|
FROM violations v
|
||||||
LEFT JOIN violation_resolutions r ON r.violation_id = v.id
|
LEFT JOIN violation_resolutions r ON r.violation_id = v.id
|
||||||
WHERE v.employee_id = ?
|
WHERE v.employee_id = ?
|
||||||
@@ -70,6 +147,14 @@ app.get('/api/violations/employee/:id', (req, res) => {
|
|||||||
res.json(rows);
|
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
|
// Helper: compute prior_active_points at time of insert
|
||||||
function getPriorActivePoints(employeeId, incidentDate) {
|
function getPriorActivePoints(employeeId, incidentDate) {
|
||||||
const row = db.prepare(
|
const row = db.prepare(
|
||||||
@@ -113,10 +198,61 @@ app.post('/api/violations', (req, res) => {
|
|||||||
priorPts
|
priorPts
|
||||||
);
|
);
|
||||||
|
|
||||||
|
audit('violation_created', 'violation', result.lastInsertRowid, submitted_by, {
|
||||||
|
employee_id, violation_type, points: ptsInt, incident_date,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(201).json({ id: result.lastInsertRowid });
|
res.status(201).json({ id: result.lastInsertRowid });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Negate a violation ──────────────────────────────────────────────────────
|
// ── 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) => {
|
app.patch('/api/violations/:id/negate', (req, res) => {
|
||||||
const { resolution_type, details, resolved_by } = req.body;
|
const { resolution_type, details, resolved_by } = req.body;
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
@@ -124,10 +260,8 @@ app.patch('/api/violations/:id/negate', (req, res) => {
|
|||||||
const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
|
const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
|
||||||
if (!violation) return res.status(404).json({ error: 'Violation not found' });
|
if (!violation) return res.status(404).json({ error: 'Violation not found' });
|
||||||
|
|
||||||
// Mark negated
|
|
||||||
db.prepare('UPDATE violations SET negated = 1 WHERE id = ?').run(id);
|
db.prepare('UPDATE violations SET negated = 1 WHERE id = ?').run(id);
|
||||||
|
|
||||||
// Upsert resolution record
|
|
||||||
const existing = db.prepare('SELECT id FROM violation_resolutions WHERE violation_id = ?').get(id);
|
const existing = db.prepare('SELECT id FROM violation_resolutions WHERE violation_id = ?').get(id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
@@ -142,10 +276,11 @@ app.patch('/api/violations/:id/negate', (req, res) => {
|
|||||||
`).run(id, resolution_type || 'Resolved', details || null, resolved_by || null);
|
`).run(id, resolution_type || 'Resolved', details || null, resolved_by || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit('violation_negated', 'violation', id, resolved_by, { resolution_type });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Restore a negated violation ─────────────────────────────────────────────
|
// ── Restore a negated violation ───────────────────────────────────────────────
|
||||||
app.patch('/api/violations/:id/restore', (req, res) => {
|
app.patch('/api/violations/:id/restore', (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
|
|
||||||
@@ -155,24 +290,46 @@ app.patch('/api/violations/:id/restore', (req, res) => {
|
|||||||
db.prepare('UPDATE violations SET negated = 0 WHERE id = ?').run(id);
|
db.prepare('UPDATE violations SET negated = 0 WHERE id = ?').run(id);
|
||||||
db.prepare('DELETE FROM violation_resolutions WHERE violation_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 });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Hard delete a violation ─────────────────────────────────────────────────
|
// ── Hard delete a violation ───────────────────────────────────────────────────
|
||||||
app.delete('/api/violations/:id', (req, res) => {
|
app.delete('/api/violations/:id', (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
|
|
||||||
const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
|
const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
|
||||||
if (!violation) return res.status(404).json({ error: 'Violation not found' });
|
if (!violation) return res.status(404).json({ error: 'Violation not found' });
|
||||||
|
|
||||||
// Delete resolution first (FK safety)
|
|
||||||
db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id);
|
db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id);
|
||||||
db.prepare('DELETE FROM violations WHERE 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 });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PDF endpoint ─────────────────────────────────────────────────────────────
|
// ── 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) => {
|
app.get('/api/violations/:id/pdf', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const violation = db.prepare(`
|
const violation = db.prepare(`
|
||||||
|
|||||||
Reference in New Issue
Block a user