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:
@@ -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'`,
|
||||
|
||||
Reference in New Issue
Block a user