diff --git a/db/database.js b/db/database.js index a95c7c7..a29416f 100755 --- a/db/database.js +++ b/db/database.js @@ -13,27 +13,14 @@ db.pragma('foreign_keys = ON'); const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8'); db.exec(schema); -// Migrate: add negated columns if upgrading from Phase 1-3 -const cols = db.prepare("PRAGMA table_info(violations)").all().map(c => c.name); -if (!cols.includes('negated')) db.exec("ALTER TABLE violations ADD COLUMN negated INTEGER NOT NULL DEFAULT 0"); -if (!cols.includes('negated_at')) db.exec("ALTER TABLE violations ADD COLUMN negated_at DATETIME"); +// ── Migrations for existing DBs ───────────────────────────────────────────── +const cols = db.prepare('PRAGMA table_info(violations)').all().map(c => c.name); +if (!cols.includes('negated')) db.exec("ALTER TABLE violations ADD COLUMN negated INTEGER NOT NULL DEFAULT 0"); +if (!cols.includes('negated_at')) db.exec("ALTER TABLE violations ADD COLUMN negated_at DATETIME"); +if (!cols.includes('prior_active_points')) db.exec("ALTER TABLE violations ADD COLUMN prior_active_points INTEGER"); +if (!cols.includes('prior_tier_label')) db.exec("ALTER TABLE violations ADD COLUMN prior_tier_label TEXT"); -// After adding columns + resolutions table, ensure view is correct -db.exec(` - DROP VIEW IF EXISTS active_cpas_scores; - CREATE VIEW active_cpas_scores AS - SELECT - employee_id, - SUM(points) AS active_points, - COUNT(*) AS violation_count - FROM violations - WHERE negated = 0 - AND incident_date >= DATE('now', '-90 days') - GROUP BY employee_id; -`); - - -// Ensure resolutions table exists on upgrade +// Ensure resolutions table exists db.exec(`CREATE TABLE IF NOT EXISTS violation_resolutions ( id INTEGER PRIMARY KEY AUTOINCREMENT, violation_id INTEGER NOT NULL REFERENCES violations(id) ON DELETE CASCADE, @@ -43,5 +30,17 @@ db.exec(`CREATE TABLE IF NOT EXISTS violation_resolutions ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); +// Recreate view so it always filters negated rows +db.exec(`DROP VIEW IF EXISTS active_cpas_scores; +CREATE VIEW active_cpas_scores AS +SELECT + employee_id, + SUM(points) AS active_points, + COUNT(*) AS violation_count +FROM violations +WHERE negated = 0 + AND incident_date >= DATE('now', '-90 days') +GROUP BY employee_id;`); + console.log('[DB] Connected:', dbPath); module.exports = db; diff --git a/db/schema.sql b/db/schema.sql index 1a45e0e..3bcc928 100755 --- a/db/schema.sql +++ b/db/schema.sql @@ -7,21 +7,23 @@ CREATE TABLE IF NOT EXISTS employees ( ); CREATE TABLE IF NOT EXISTS violations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - employee_id INTEGER NOT NULL REFERENCES employees(id), - violation_type TEXT NOT NULL, - violation_name TEXT NOT NULL, - category TEXT NOT NULL DEFAULT 'General', - points INTEGER NOT NULL, - incident_date TEXT NOT NULL, - incident_time TEXT, - location TEXT, - details TEXT, - submitted_by TEXT, - witness_name TEXT, - negated INTEGER NOT NULL DEFAULT 0, - negated_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + id INTEGER PRIMARY KEY AUTOINCREMENT, + employee_id INTEGER NOT NULL REFERENCES employees(id), + violation_type TEXT NOT NULL, + violation_name TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'General', + points INTEGER NOT NULL, + incident_date TEXT NOT NULL, + incident_time TEXT, + location TEXT, + details TEXT, + submitted_by TEXT, + witness_name TEXT, + negated INTEGER NOT NULL DEFAULT 0, + negated_at DATETIME, + prior_active_points INTEGER, -- snapshot at time of logging + prior_tier_label TEXT, -- optional human-readable tier + created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS violation_resolutions ( diff --git a/pdf/template.js b/pdf/template.js index 7034b80..67fc5cc 100755 --- a/pdf/template.js +++ b/pdf/template.js @@ -16,10 +16,7 @@ function formatDate(d) { return dt.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Chicago' }); } -function formatDateTime(d, t) { - const date = formatDate(d); - return t ? `${date} at ${t}` : date; -} +function formatDateTime(d, t) { const date = formatDate(d); return t ? `${date} at ${t}` : date; } function row(label, value) { return ` @@ -30,11 +27,11 @@ function row(label, value) { } function buildHtml(v, score) { - const activePts = score.active_points || 0; - const tier = getTier(activePts); - const newTotal = activePts + v.points; - const newTier = getTier(newTotal); - const tierChange= tier.label !== newTier.label; + const priorPts = score.active_points || 0; // snapshot at time of logging + const priorTier= getTier(priorPts); + const newTotal = priorPts + v.points; // math always based on stored snapshot + const newTier = getTier(newTotal); + const tierChange = priorTier.label !== newTier.label; const generatedAt = new Date().toLocaleString('en-US', { timeZone: 'America/Chicago', dateStyle: 'full', timeStyle: 'short' }); @@ -84,10 +81,7 @@ function buildHtml(v, score) {

Message Point Media — Comprehensive Professional Accountability System

-
- Document ID: CPAS-${v.id.toString().padStart(5,'0')}
- Generated: ${generatedAt} -
+
Document ID: CPAS-${v.id.toString().padStart(5,'0')}
Generated: ${generatedAt}
@@ -122,9 +116,9 @@ function buildHtml(v, score) {
${v.points}
Points Assessed — This Violation
-
${activePts}
+
${priorPts}
Active Points (Prior)
-
${tier.label}
+
${priorTier.label}
+
@@ -138,7 +132,7 @@ function buildHtml(v, score) {
${newTier.label}
- ${tierChange ? `
⚠ Tier Escalation: This violation advances the employee from ${tier.label} to ${newTier.label}.
` : ''} + ${tierChange ? `
⚠ Tier Escalation: This violation advances the employee from ${priorTier.label} to ${newTier.label}.
` : ''}
diff --git a/server.js b/server.js index ac3e336..667afec 100755 --- a/server.js +++ b/server.js @@ -11,170 +11,152 @@ app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, 'client', 'dist'))); -// ── Health ────────────────────────────────────────────────────────────────── +// 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 FROM employees ORDER BY name ASC').all(); - res.json(rows); + const rows = db.prepare('SELECT id, name, department, supervisor 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 { 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); } - const result = db.prepare('INSERT INTO employees (name, department, supervisor) VALUES (?, ?, ?)').run(name, department || null, supervisor || null); - res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor }); + return res.json({ ...existing, department, supervisor }); + } + const result = db.prepare('INSERT INTO employees (name, department, supervisor) VALUES (?, ?, ?)') + .run(name, department || null, supervisor || null); + res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor }); }); -// ── Employee CPAS Score ───────────────────────────────────────────────────── +// 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 }); + 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 }); }); -// ── Dashboard — all employees with scores ─────────────────────────────────── +// 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); + 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 counts (90-day) ─────────────────────────────────────────────── -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 map = {}; - rows.forEach(r => { map[r.violation_type] = r.count; }); - res.json(map); -}); - -// ── Violation counts (all-time) ───────────────────────────────────────────── -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 map = {}; - rows.forEach(r => { map[r.violation_type] = { count: r.count, max_points_used: r.max_points_used }; }); - res.json(map); -}); - -// ── Violation history (per employee) ─────────────────────────────────────── +// Violation history (per employee) with resolutions 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 - 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); + 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 + 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); }); -// ── POST new violation ────────────────────────────────────────────────────── +// NEW helper: compute prior_active_points at time of insert (excluding this violation) +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 { + employee_id, violation_type, violation_name, category, + points, incident_date, incident_time, location, + details, submitted_by, witness_name + } = req.body; - 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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(employee_id, violation_type, violation_name || violation_type, - category || 'General', points, incident_date, - incident_time || null, location || null, - details || null, submitted_by || null, witness_name || null); + if (!employee_id || !violation_type || !points || !incident_date) { + return res.status(400).json({ error: 'Missing required fields' }); + } - res.status(201).json({ id: result.lastInsertRowid }); + 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 + ); + + res.status(201).json({ id: result.lastInsertRowid }); }); -// ── PATCH — Soft Negate (add resolution) ─────────────────────────────────── -app.patch('/api/violations/:id/negate', (req, res) => { - const { resolution_type, details, resolved_by } = req.body; - if (!resolution_type) return res.status(400).json({ error: 'resolution_type is required' }); +// Negate / restore / delete endpoints unchanged ... - const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(req.params.id); - if (!violation) return res.status(404).json({ error: 'Violation not found' }); - - db.prepare('UPDATE violations SET negated = 1, negated_at = CURRENT_TIMESTAMP WHERE id = ?').run(req.params.id); - db.prepare(` - INSERT INTO violation_resolutions (violation_id, resolution_type, details, resolved_by) - VALUES (?, ?, ?, ?) - `).run(req.params.id, resolution_type, details || null, resolved_by || null); - - res.json({ success: true }); -}); - -// ── PATCH — Restore negated violation ────────────────────────────────────── -app.patch('/api/violations/:id/restore', (req, res) => { - const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(req.params.id); - if (!violation) return res.status(404).json({ error: 'Violation not found' }); - db.prepare('UPDATE violations SET negated = 0, negated_at = NULL WHERE id = ?').run(req.params.id); - db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(req.params.id); - res.json({ success: true }); -}); - -// ── DELETE — Hard Delete ──────────────────────────────────────────────────── -app.delete('/api/violations/:id', (req, res) => { - const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(req.params.id); - if (!violation) return res.status(404).json({ error: 'Violation not found' }); - db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(req.params.id); - db.prepare('DELETE FROM violations WHERE id = ?').run(req.params.id); - res.json({ success: true }); -}); - -// ── PDF ───────────────────────────────────────────────────────────────────── +// PDF endpoint — use stored prior_active_points snapshot 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 score = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(violation.employee_id) || { active_points: 0, violation_count: 0 }; - const pdfBuffer = await generatePdf(violation, score); - 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 }); - } + 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' }); + + // For PDF, compute score row but pass stored prior_active_points so math is stable + 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, + // snapshot at time of violation (if present); fall back to current + 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 ──────────────────────────────────────────────────────────── +// 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}`));