Auto-sync .kiro/ from master (post-checkout hook)
This commit is contained in:
279
.kiro/specs/remediation-plan-history/design.md
Normal file
279
.kiro/specs/remediation-plan-history/design.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user