From 1fe6c1f84ca2f96b6aa7b418374e37b34b1d1abe Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Fri, 15 May 2026 10:53:14 -0600 Subject: [PATCH] Add remediation plan and resolution date history tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New table compliance_item_history stores an append-only audit trail of changes to resolution_date and remediation_plan. The current values remain on compliance_items for fast VCL reporting queries (no double-counting). Backend: - Migration: creates compliance_item_history with indexes - PATCH /items/:hostname/metadata: records old→new in history before updating, accepts optional change_reason field (max 500 chars) - GET /items/:hostname: returns history array (last 10 entries, newest first) - POST /vcl/bulk-commit: records history for each changed field per hostname Frontend: - ComplianceDetailPanel: added change reason input below Save button - Added Change History section showing field changes with timestamps, usernames, old→new values, and reasons - Re-fetches detail after save to show updated history immediately Tests updated to match new transaction-based PATCH flow. --- .../vcl-compliance-reporting.test.js | 34 ++++- .../migrations/add_compliance_item_history.js | 44 ++++++ backend/migrations/run-all.js | 1 + backend/routes/compliance.js | 144 +++++++++++++++--- .../components/pages/ComplianceDetailPanel.js | 54 ++++++- 5 files changed, 254 insertions(+), 23 deletions(-) create mode 100644 backend/migrations/add_compliance_item_history.js diff --git a/backend/__tests__/vcl-compliance-reporting.test.js b/backend/__tests__/vcl-compliance-reporting.test.js index 781413c..93108b1 100644 --- a/backend/__tests__/vcl-compliance-reporting.test.js +++ b/backend/__tests__/vcl-compliance-reporting.test.js @@ -112,7 +112,27 @@ beforeEach(() => { describe('PATCH /items/:hostname/metadata', () => { it('happy path — updates resolution_date and remediation_plan', async () => { - mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 2 }); + // Mock client.query: first call = SELECT current values, second+ = INSERT history / UPDATE + const mockClient = { + query: jest.fn() + .mockResolvedValueOnce({ rows: [{ resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT current + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // BEGIN + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (resolution_date) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (remediation_plan) + .mockResolvedValueOnce({ rows: [], rowCount: 2 }) // UPDATE items + .mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT + release: jest.fn(), + }; + // Override connect to return our mock client + mockPool.connect.mockResolvedValueOnce(mockClient); + // The first call from the handler is BEGIN, then SELECT, then inserts, then UPDATE, then COMMIT + mockClient.query = jest.fn() + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN + .mockResolvedValueOnce({ rows: [{ resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT current values + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (resolution_date) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (remediation_plan) + .mockResolvedValueOnce({ rows: [], rowCount: 2 }) // UPDATE items + .mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', { resolution_date: '2026-06-15', @@ -143,7 +163,14 @@ describe('PATCH /items/:hostname/metadata', () => { }); it('returns 404 when hostname not found', async () => { - mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); + const mockClient = { + query: jest.fn() + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT current values — empty = not found + .mockResolvedValueOnce({ rows: [], rowCount: 0 }), // ROLLBACK + release: jest.fn(), + }; + mockPool.connect.mockResolvedValueOnce(mockClient); const res = await request(server, 'PATCH', '/api/compliance/items/nonexistent-host/metadata', { resolution_date: '2026-06-15', @@ -277,6 +304,9 @@ describe('Integration: full bulk upload flow (preview → commit)', () => { }; mockClient.query .mockResolvedValueOnce({}) // BEGIN + .mockResolvedValueOnce({ rows: [{ hostname: 'srv-001', resolution_date: null, remediation_plan: null }] }) // SELECT current values for all hostnames + .mockResolvedValueOnce({ rowCount: 1 }) // INSERT history (resolution_date) + .mockResolvedValueOnce({ rowCount: 1 }) // INSERT history (remediation_plan) .mockResolvedValueOnce({ rowCount: 1 }) // UPDATE srv-001 .mockResolvedValueOnce({}); // COMMIT diff --git a/backend/migrations/add_compliance_item_history.js b/backend/migrations/add_compliance_item_history.js new file mode 100644 index 0000000..673be1a --- /dev/null +++ b/backend/migrations/add_compliance_item_history.js @@ -0,0 +1,44 @@ +const pool = require('../db'); + +async function run() { + console.log('Starting compliance_item_history migration...'); + try { + await pool.query(` + CREATE TABLE IF NOT EXISTS compliance_item_history ( + id SERIAL PRIMARY KEY, + hostname TEXT NOT NULL, + field_name TEXT NOT NULL CHECK (field_name IN ('resolution_date', 'remediation_plan')), + old_value TEXT, + new_value TEXT, + change_reason TEXT, + changed_by TEXT NOT NULL, + changed_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + console.log('✓ compliance_item_history table created (or already exists)'); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_field + ON compliance_item_history(hostname, field_name) + `); + console.log('✓ hostname/field_name index created'); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_compliance_history_changed_at + ON compliance_item_history(changed_at) + `); + console.log('✓ changed_at index created'); + + console.log('Migration complete.'); + } catch (err) { + console.error('Migration failed:', err.message); + throw err; + } +} + +module.exports = { run }; + +// Self-execute when run directly +if (require.main === module) { + run().then(() => process.exit(0)).catch(() => process.exit(1)); +} diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js index 506eb84..a4242c0 100644 --- a/backend/migrations/run-all.js +++ b/backend/migrations/run-all.js @@ -20,6 +20,7 @@ const POSTGRES_MIGRATIONS = [ 'add_vcl_reporting_columns.js', 'add_vcl_vertical_metadata.js', 'add_vcl_multi_vertical.js', + 'add_compliance_item_history.js', ]; async function runAll() { diff --git a/backend/routes/compliance.js b/backend/routes/compliance.js index 3796884..d841a39 100644 --- a/backend/routes/compliance.js +++ b/backend/routes/compliance.js @@ -598,10 +598,23 @@ function createComplianceRouter(upload) { WHERE cn.hostname = $1 ORDER BY cn.created_at DESC`, [hostname] ); + // Fetch remediation history + let history = []; + try { + const { rows: historyRows } = await pool.query( + `SELECT id, field_name, old_value, new_value, change_reason, changed_by, changed_at + FROM compliance_item_history WHERE hostname = $1 ORDER BY changed_at DESC LIMIT 10`, + [hostname] + ); + history = historyRows; + } catch (histErr) { + console.error('[Compliance] History fetch error:', histErr.message); + } + const identity = metricRows.find(r => r.status === 'active') || metricRows[0]; // Return resolution_date and remediation_plan from the first active item (or any item) const resDate = identity.resolution_date ? (typeof identity.resolution_date === 'string' ? identity.resolution_date : identity.resolution_date.toISOString().slice(0, 10)) : null; - res.json({ hostname, ip_address: identity.ip_address || '', device_type: identity.device_type || '', team: identity.team || '', resolution_date: resDate, remediation_plan: identity.remediation_plan || '', metrics, notes }); + res.json({ hostname, ip_address: identity.ip_address || '', device_type: identity.device_type || '', team: identity.team || '', resolution_date: resDate, remediation_plan: identity.remediation_plan || '', metrics, notes, history }); } catch (err) { console.error('[Compliance] GET /items/:hostname error:', err.message); res.status(500).json({ error: 'Database error' }); @@ -851,7 +864,7 @@ function createComplianceRouter(upload) { const hostname = req.params.hostname; if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' }); - const { resolution_date, remediation_plan } = req.body; + const { resolution_date, remediation_plan, change_reason } = req.body; // Validate resolution_date: must be a valid ISO date string or null if (resolution_date !== undefined && resolution_date !== null) { @@ -868,34 +881,83 @@ function createComplianceRouter(upload) { } } - try { - // Build dynamic SET clause for provided fields only - const setClauses = []; - const values = []; - let paramIdx = 1; + // Validate change_reason: optional, max 500 chars + if (change_reason !== undefined && change_reason !== null && change_reason.length > 500) { + return res.status(400).json({ error: 'Change reason exceeds 500 characters' }); + } + const setClauses = []; + const values = []; + let paramIdx = 1; + + if (resolution_date !== undefined) { + setClauses.push(`resolution_date = $${paramIdx++}`); + values.push(resolution_date); + } + if (remediation_plan !== undefined) { + setClauses.push(`remediation_plan = $${paramIdx++}`); + values.push(remediation_plan); + } + + if (setClauses.length === 0) { + return res.status(400).json({ error: 'No fields to update' }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Get current values before updating + const { rows: currentRows } = await client.query( + `SELECT DISTINCT ON (hostname) resolution_date, remediation_plan + FROM compliance_items WHERE hostname = $1 AND status = 'active' + ORDER BY hostname, id DESC LIMIT 1`, + [hostname] + ); + + if (currentRows.length === 0) { + await client.query('ROLLBACK'); + client.release(); + return res.status(404).json({ error: 'Device not found' }); + } + + const current = currentRows[0]; + const currentResDate = current.resolution_date + ? (typeof current.resolution_date === 'string' ? current.resolution_date : current.resolution_date.toISOString().slice(0, 10)) + : null; + const currentPlan = current.remediation_plan || null; + const reasonText = change_reason && change_reason.trim() ? change_reason.trim() : null; + + // Insert history for each changed field if (resolution_date !== undefined) { - setClauses.push(`resolution_date = $${paramIdx++}`); - values.push(resolution_date); + const newVal = resolution_date || null; + if (currentResDate !== newVal) { + await client.query( + `INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by) + VALUES ($1, 'resolution_date', $2, $3, $4, $5)`, + [hostname, currentResDate, newVal, reasonText, req.user.username] + ); + } } if (remediation_plan !== undefined) { - setClauses.push(`remediation_plan = $${paramIdx++}`); - values.push(remediation_plan); - } - - if (setClauses.length === 0) { - return res.status(400).json({ error: 'No fields to update' }); + const newVal = remediation_plan || null; + if (currentPlan !== newVal) { + await client.query( + `INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by) + VALUES ($1, 'remediation_plan', $2, $3, $4, $5)`, + [hostname, currentPlan, newVal, reasonText, req.user.username] + ); + } } + // Update the items values.push(hostname); - const result = await pool.query( + const result = await client.query( `UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx}`, values ); - if (result.rowCount === 0) { - return res.status(404).json({ error: 'Device not found' }); - } + await client.query('COMMIT'); logAudit({ userId: req.user.id, @@ -903,14 +965,17 @@ function createComplianceRouter(upload) { action: 'compliance_metadata_update', entityType: 'compliance_item', entityId: hostname, - details: { resolution_date, remediation_plan }, + details: { resolution_date, remediation_plan, change_reason: reasonText }, ipAddress: req.ip, }); res.json({ updated: result.rowCount }); } catch (err) { + await client.query('ROLLBACK'); console.error('[Compliance] PATCH /items/:hostname/metadata error:', err.message); res.status(500).json({ error: 'Failed to update device metadata' }); + } finally { + client.release(); } }); @@ -1280,6 +1345,22 @@ function createComplianceRouter(upload) { try { await client.query('BEGIN'); + // Pre-fetch current values for all hostnames in the batch + const hostnames = changes.map(c => c.hostname); + const { rows: currentRows } = await client.query( + `SELECT DISTINCT ON (hostname) hostname, resolution_date, remediation_plan + FROM compliance_items WHERE status = 'active' AND hostname = ANY($1) + ORDER BY hostname, id DESC`, + [hostnames] + ); + const currentData = new Map(); + for (const row of currentRows) { + currentData.set(row.hostname, { + resolution_date: row.resolution_date ? (typeof row.resolution_date === 'string' ? row.resolution_date : row.resolution_date.toISOString().slice(0, 10)) : null, + remediation_plan: row.remediation_plan || null, + }); + } + let committedCount = 0; for (const change of changes) { const setClauses = []; @@ -1301,6 +1382,29 @@ function createComplianceRouter(upload) { if (setClauses.length === 0) continue; + // Record history for changed fields + const current = currentData.get(change.hostname) || { resolution_date: null, remediation_plan: null }; + if (change.resolution_date !== undefined) { + const newVal = change.resolution_date || null; + if (current.resolution_date !== newVal) { + await client.query( + `INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by) + VALUES ($1, 'resolution_date', $2, $3, NULL, $4)`, + [change.hostname, current.resolution_date, newVal, req.user.username] + ); + } + } + if (change.remediation_plan !== undefined) { + const newVal = change.remediation_plan || null; + if (current.remediation_plan !== newVal) { + await client.query( + `INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by) + VALUES ($1, 'remediation_plan', $2, $3, NULL, $4)`, + [change.hostname, current.remediation_plan, newVal, req.user.username] + ); + } + } + values.push(change.hostname); const result = await client.query( `UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`, diff --git a/frontend/src/components/pages/ComplianceDetailPanel.js b/frontend/src/components/pages/ComplianceDetailPanel.js index 41f9ebb..bccd939 100644 --- a/frontend/src/components/pages/ComplianceDetailPanel.js +++ b/frontend/src/components/pages/ComplianceDetailPanel.js @@ -51,6 +51,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, // Metadata fields const [resolutionDate, setResolutionDate] = useState(''); const [remediationPlan, setRemediationPlan] = useState(''); + const [changeReason, setChangeReason] = useState(''); const [metaSaving, setMetaSaving] = useState(false); const [metaError, setMetaError] = useState(null); @@ -58,14 +59,19 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, setMetaSaving(true); setMetaError(null); try { + const body = { ...fields }; + if (changeReason.trim()) body.change_reason = changeReason.trim(); const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}/metadata`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(fields), + body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to save metadata'); + setChangeReason(''); + // Re-fetch to get updated history + await fetchDetail(); } catch (err) { setMetaError(err.message); } finally { @@ -315,8 +321,54 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, {metaError &&
{metaError}
} + {/* Change reason input */} + { if (e.target.value.length <= 500) setChangeReason(e.target.value); }} + placeholder="Reason for change (optional)" + style={{ + width: '100%', marginTop: '0.5rem', + background: 'rgba(15,23,42,0.6)', + border: '1px solid rgba(255,255,255,0.08)', + borderRadius: '0.25rem', + color: '#94A3B8', + padding: '0.35rem 0.5rem', + fontSize: '0.7rem', + outline: 'none', + boxSizing: 'border-box', + }} + onFocus={e => e.target.style.borderColor = 'rgba(20,184,166,0.4)'} + onBlur={e => e.target.style.borderColor = 'rgba(255,255,255,0.08)'} + /> + {/* Change History */} + {detail.history && detail.history.length > 0 && ( +
}> + {detail.history.map(h => ( +
+
+ + {h.field_name === 'resolution_date' ? '📅' : '📋'} {h.field_name.replace('_', ' ')} + + + {h.changed_at ? new Date(h.changed_at).toLocaleDateString() : ''} + +
+
+ {h.old_value || '—'} + + {h.field_name === 'remediation_plan' ? (h.new_value && h.new_value.length > 60 ? h.new_value.slice(0, 60) + '…' : (h.new_value || '—')) : (h.new_value || '—')} +
+
+ {h.changed_by}{h.change_reason ? ` · ${h.change_reason}` : ''} +
+
+ ))} +
+ )} + {/* Notes */}
} grow> {detail.notes.length === 0 && (