280 lines
13 KiB
Markdown
280 lines
13 KiB
Markdown
# Design Document: Remediation Plan History
|
|
|
|
## Overview
|
|
|
|
Adds an append-only audit trail for resolution_date and remediation_plan changes on compliance items. The design preserves the existing compliance_items schema (current values remain directly queryable) and introduces a new `compliance_item_history` table for historical entries. The pattern mirrors how `compliance_notes` works — separate rows with timestamps and attribution.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ ComplianceDetailPanel │
|
|
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
|
|
│ │ Resolution │ │ Remediation │ │ Change Reason (text) │ │
|
|
│ │ Date Input │ │ Plan Input │ │ │ │
|
|
│ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │
|
|
│ │ │ │ │
|
|
│ └──────────────────┴──────────────────────┘ │
|
|
│ │ │
|
|
│ PATCH /metadata │
|
|
│ { resolution_date, remediation_plan, │
|
|
│ change_reason } │
|
|
│ │ │
|
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
|
│ │ Change History Section │ │
|
|
│ │ [date] [field] [old→new] [by user] [reason] │ │
|
|
│ └─────────────────────────────────────────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ Backend (compliance.js) │
|
|
│ │
|
|
│ PATCH /items/:hostname/metadata │
|
|
│ 1. Validate inputs │
|
|
│ 2. SELECT current values from compliance_items │
|
|
│ 3. Compare old vs new — skip if identical │
|
|
│ 4. INSERT into compliance_item_history (per changed field) │
|
|
│ 5. UPDATE compliance_items with new values │
|
|
│ │
|
|
│ GET /items/:hostname │
|
|
│ (existing) + SELECT from compliance_item_history │
|
|
│ LIMIT 10 ORDER BY changed_at DESC │
|
|
│ │
|
|
│ POST /vcl/bulk-commit │
|
|
│ For each hostname: same compare-then-insert pattern │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ PostgreSQL │
|
|
│ │
|
|
│ compliance_items (unchanged) │
|
|
│ resolution_date DATE │
|
|
│ remediation_plan TEXT │
|
|
│ │
|
|
│ compliance_item_history (new) │
|
|
│ id, hostname, field_name, old_value, new_value, │
|
|
│ change_reason, changed_by, changed_at │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
## Data Model
|
|
|
|
### New Table: compliance_item_history
|
|
|
|
```sql
|
|
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()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_field
|
|
ON compliance_item_history(hostname, field_name);
|
|
CREATE INDEX IF NOT EXISTS idx_compliance_history_changed_at
|
|
ON compliance_item_history(changed_at);
|
|
```
|
|
|
|
### Existing Table: compliance_items (no changes)
|
|
|
|
The `resolution_date` and `remediation_plan` columns remain as-is. They continue to hold the current/latest value for direct querying by VCL reports.
|
|
|
|
## API Changes
|
|
|
|
### PATCH /api/compliance/items/:hostname/metadata
|
|
|
|
**Request body changes:**
|
|
|
|
```json
|
|
{
|
|
"resolution_date": "2026-03-15",
|
|
"remediation_plan": "Upgrade firmware to v4.2",
|
|
"change_reason": "Vendor pushed back delivery date"
|
|
}
|
|
```
|
|
|
|
New optional field: `change_reason` (string, max 500 characters, nullable).
|
|
|
|
**Behavior changes:**
|
|
|
|
1. Before updating compliance_items, query the current values:
|
|
```sql
|
|
SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
|
|
FROM compliance_items
|
|
WHERE hostname = $1 AND status = 'active'
|
|
ORDER BY hostname, id DESC
|
|
LIMIT 1
|
|
```
|
|
|
|
2. For each field being updated, compare old vs new. If different, insert a history row:
|
|
```sql
|
|
INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
```
|
|
|
|
3. Then proceed with the existing UPDATE as before.
|
|
|
|
**Response:** unchanged (`{ updated: number }`).
|
|
|
|
### GET /api/compliance/items/:hostname
|
|
|
|
**Response changes:**
|
|
|
|
Add a `history` array to the response:
|
|
|
|
```json
|
|
{
|
|
"hostname": "server01.example.com",
|
|
"ip_address": "10.0.1.5",
|
|
"device_type": "Server",
|
|
"team": "STEAM",
|
|
"resolution_date": "2026-03-15",
|
|
"remediation_plan": "Upgrade firmware to v4.2",
|
|
"metrics": [...],
|
|
"notes": [...],
|
|
"history": [
|
|
{
|
|
"id": 42,
|
|
"field_name": "resolution_date",
|
|
"old_value": "2026-02-01",
|
|
"new_value": "2026-03-15",
|
|
"change_reason": "Vendor pushed back delivery date",
|
|
"changed_by": "jsmith",
|
|
"changed_at": "2026-01-20T14:30:00.000Z"
|
|
},
|
|
{
|
|
"id": 41,
|
|
"field_name": "remediation_plan",
|
|
"old_value": null,
|
|
"new_value": "Upgrade firmware to v4.2",
|
|
"change_reason": null,
|
|
"changed_by": "jsmith",
|
|
"changed_at": "2026-01-15T09:00:00.000Z"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
Query:
|
|
```sql
|
|
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
|
|
```
|
|
|
|
### POST /api/compliance/vcl/bulk-commit
|
|
|
|
**Behavior changes:**
|
|
|
|
Within the transaction, before updating each hostname:
|
|
1. Query current values for that hostname
|
|
2. Compare each field (resolution_date, remediation_plan) against the incoming value
|
|
3. Insert history rows for changed fields with `changed_by` set to `req.user.username`
|
|
|
|
No request/response shape changes. The `change_reason` is not supported for bulk updates (would require per-row reasons which adds complexity without clear value for mass imports).
|
|
|
|
## Frontend Changes
|
|
|
|
### ComplianceDetailPanel.js
|
|
|
|
**New state:**
|
|
- `changeReason` (string) — text input value for the reason field
|
|
- `history` (array) — populated from the API response
|
|
|
|
**New UI elements:**
|
|
|
|
1. **Change Reason input** — a single-line text input placed between the remediation plan save button and the notes section. Cleared after each successful save.
|
|
|
|
2. **Change History section** — a new `<Section>` component placed between the Remediation Plan section and the Notes section. Displays up to 10 history entries in reverse chronological order.
|
|
|
|
**History entry display format:**
|
|
```
|
|
[field_name icon] old_value → new_value
|
|
username · 2026-01-20 [reason if present]
|
|
```
|
|
|
|
- Resolution date entries: show dates as YYYY-MM-DD, NULL shown as "—"
|
|
- Remediation plan entries: truncate old/new values to 60 characters with "…" suffix; full text shown on hover via title attribute
|
|
- Change reason: displayed in muted text below the change line when present
|
|
|
|
**Save flow update:**
|
|
- The `handleSaveMetadata` function passes `change_reason` alongside the field values
|
|
- After successful save, clear the `changeReason` input and re-fetch detail (which now includes updated history)
|
|
|
|
## Error Handling
|
|
|
|
- If the history INSERT fails, the entire PATCH operation should fail (history is part of the same logical operation). Use a transaction wrapping both the history insert and the compliance_items update.
|
|
- If the history SELECT for the GET endpoint fails, return the device detail without history (graceful degradation) and log the error.
|
|
- `change_reason` validation: max 500 characters, trimmed. If over 500, return 400 with descriptive error.
|
|
|
|
## Migration
|
|
|
|
File: `backend/migrations/add_compliance_item_history.js`
|
|
|
|
```javascript
|
|
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 };
|
|
```
|
|
|
|
## Reporting Isolation
|
|
|
|
No changes to VCL reporting queries. The following queries continue to read directly from `compliance_items.resolution_date`:
|
|
|
|
- Donut chart: `SELECT hostname, MAX(resolution_date) FROM compliance_items WHERE status = 'active' GROUP BY hostname`
|
|
- Burndown forecast: `SELECT resolution_date FROM compliance_items WHERE status = 'active' AND resolution_date IS NOT NULL`
|
|
- Per-vertical burndown in vclMultiVertical.js
|
|
|
|
The `compliance_item_history` table is never referenced by any reporting query.
|
|
|
|
## Performance Considerations
|
|
|
|
- History inserts are lightweight (single row per field change). Even bulk updates with 500 hostnames produce at most 1000 history rows per commit — well within PostgreSQL's transaction capacity.
|
|
- The LIMIT 10 on history retrieval prevents unbounded result sets for devices with many changes.
|
|
- Indexes on (hostname, field_name) and (changed_at) ensure fast lookups without full table scans.
|
|
- No additional queries are added to the VCL reporting paths.
|