Add remediation plan and resolution date history tracking

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.
This commit is contained in:
Jordan Ramos
2026-05-15 10:53:14 -06:00
parent 97e5d68d8e
commit 1fe6c1f84c
5 changed files with 254 additions and 23 deletions

View File

@@ -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'`,