Files
cve-dashboard/.kiro/specs/compliance-multi-metric-notes/design.md

291 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design Document: Multi-Metric Notes for Compliance Detail Panel
## Overview
This feature extends the compliance notes system so that a single note can be associated with multiple metrics in one action. Today, the `ComplianceDetailPanel` uses a single-select `<select>` dropdown to pick one metric before adding a note. When a remediation action covers several metrics on the same device, the analyst must repeat the note for each metric individually.
The change touches three layers:
1. **Database** — add a `group_id` column to `compliance_notes` so notes created together can be identified as a batch.
2. **API** — extend `POST /api/compliance/notes` to accept `metric_ids` (array) alongside the existing `metric_id` (string), inserting one row per metric inside a transaction.
3. **Frontend** — replace the single-select dropdown with a multi-select chip-based selector, add Select All / Deselect All, and group notes by `group_id` in the display.
Backward compatibility is preserved: the existing `metric_id` field continues to work, and notes created before this feature (which lack a `group_id`) render exactly as they do today.
## Architecture
The feature follows the existing compliance module architecture. No new files or route modules are introduced — changes are scoped to the existing `compliance.js` route file and `ComplianceDetailPanel.js` component.
```mermaid
sequenceDiagram
participant User
participant DetailPanel as ComplianceDetailPanel
participant API as POST /api/compliance/notes
participant DB as SQLite (compliance_notes)
User->>DetailPanel: Select multiple metrics via chip selector
User->>DetailPanel: Type note text, click Send
DetailPanel->>API: POST { hostname, metric_ids: [...], note }
API->>API: Validate inputs (note text, metric IDs)
API->>API: Generate group_id (UUID)
API->>DB: BEGIN TRANSACTION
loop For each metric_id in metric_ids
API->>DB: INSERT INTO compliance_notes (hostname, metric_id, note, group_id, created_by, created_at)
end
API->>DB: COMMIT
API->>DetailPanel: 201 { notes: [...created rows] }
DetailPanel->>DetailPanel: Group notes by group_id, refresh display
```
## Components and Interfaces
### Backend
**Modified: `POST /api/compliance/notes`**
Request body accepts either format:
```javascript
// New multi-metric format
{ hostname: "SERVER01", metric_ids: ["2.1.1", "2.3.2", "4.1.1"], note: "Vendor ticket VT-1234 opened" }
// Legacy single-metric format (still supported)
{ hostname: "SERVER01", metric_id: "2.1.1", note: "Vendor ticket VT-1234 opened" }
```
Precedence: if both `metric_id` and `metric_ids` are present, `metric_ids` wins.
Validation rules:
- `hostname` — required, string, 1300 chars, matches `/^[a-zA-Z0-9._-]+$/` (unchanged)
- `metric_ids` — array of strings, each non-empty and ≤50 chars, at least one entry
- `note` — required, non-empty after trimming, max 1000 chars (unchanged)
On success, the endpoint returns all created rows (with `username` joined from `users`) so the frontend can update without a separate fetch.
**New: Migration script `backend/migrations/add_compliance_notes_group_id.js`**
Adds the `group_id` column and backfills existing rows:
```sql
ALTER TABLE compliance_notes ADD COLUMN group_id TEXT;
CREATE INDEX idx_compliance_notes_group ON compliance_notes(group_id);
-- Backfill: each existing row gets its own unique group_id
UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL;
```
The backfill ensures every row has a `group_id`, so the frontend grouping logic works uniformly without null checks.
### Frontend
**Modified: `ComplianceDetailPanel.js`**
The notes section is updated with three changes:
1. **Multi-select metric selector** — replaces the `<select>` dropdown with a chip-based toggle list. Each active metric is rendered as a clickable `MetricChip`. Selected chips get a highlighted border/background. A "Select All" / "Deselect All" toggle appears when there are 2+ active metrics.
2. **Submission logic**`handleAddNote` sends `metric_ids` (array of selected metric IDs) instead of `metric_id` (single string). The submit button is disabled when no metrics are selected or note text is empty.
3. **Note display grouping** — notes are grouped by `group_id` before rendering. Notes sharing a `group_id` are displayed as a single card with multiple `MetricChip` badges. Notes without a `group_id` (pre-migration legacy) render as individual entries, same as today.
**Component structure:**
```
ComplianceDetailPanel
├── Header (hostname, IP, device type, team)
├── Section: Failing Metrics
│ └── MetricRow (per active metric)
├── Section: Resolved Metrics
│ └── MetricRow (per resolved metric)
├── Section: History
│ └── MetricChip + seen count (per active metric)
└── Section: Notes
├── NoteCard (per group_id group, shows multiple MetricChips if multi-metric)
└── Add Note Form
├── MetricChipSelector (multi-select chip toggles)
│ ├── MetricChip (per active metric, clickable)
│ └── Select All / Deselect All toggle
├── Textarea (note text)
└── Send button (disabled when no metrics selected or text empty)
```
**MetricChipSelector behavior:**
| State | Behavior |
|---|---|
| 1 active metric | Chip is pre-selected and non-removable. No Select All toggle. |
| 2+ active metrics, panel just opened | First metric pre-selected. Select All toggle visible. |
| User clicks unselected chip | Chip added to selection |
| User clicks selected chip (2+ selected) | Chip removed from selection |
| User clicks selected chip (only 1 selected, 2+ metrics exist) | No-op — at least one must remain selected |
| Select All clicked | All active metrics selected, toggle label changes to "Deselect All" |
| Deselect All clicked | All metrics deselected except the first (to maintain minimum selection) |
**Design rationale — minimum selection of 1:** The submit button is disabled when no metrics are selected (Requirement 3.4). Rather than allowing the user to reach an empty state and see a disabled button, "Deselect All" keeps the first metric selected. This matches the current UX where a metric is always selected.
## Data Models
### compliance_notes table (modified)
| Column | Type | Description |
|---|---|---|
| `id` | INTEGER PK | Auto-increment row ID |
| `hostname` | TEXT NOT NULL | Device hostname |
| `metric_id` | TEXT NOT NULL | Compliance metric ID |
| `note` | TEXT NOT NULL | Note text (max 1000 chars) |
| `group_id` | TEXT | Batch identifier — rows from the same submission share this value |
| `created_by` | INTEGER FK | User ID of the note author |
| `created_at` | DATETIME | Timestamp of creation |
The `group_id` is a UUID v4 string generated server-side via `crypto.randomUUID()`. Single-metric submissions also receive a `group_id` so the frontend grouping logic is uniform.
**Index:** `idx_compliance_notes_group ON compliance_notes(group_id)` — supports the frontend's grouping query.
### API Response Shape
`POST /api/compliance/notes` response (201):
```json
{
"notes": [
{
"id": 42,
"hostname": "SERVER01",
"metric_id": "2.1.1",
"note": "Vendor ticket VT-1234 opened",
"group_id": "a1b2c3d4-...",
"created_at": "2025-01-15 14:30:00",
"created_by": "jsmith"
},
{
"id": 43,
"hostname": "SERVER01",
"metric_id": "2.3.2",
"note": "Vendor ticket VT-1234 opened",
"group_id": "a1b2c3d4-...",
"created_at": "2025-01-15 14:30:00",
"created_by": "jsmith"
}
]
}
```
`GET /api/compliance/items/:hostname` response — the existing `notes` array now includes `group_id`:
```json
{
"notes": [
{ "id": 43, "metric_id": "2.3.2", "note": "...", "group_id": "a1b2c3d4-...", "created_at": "...", "created_by": "jsmith" },
{ "id": 42, "metric_id": "2.1.1", "note": "...", "group_id": "a1b2c3d4-...", "created_at": "...", "created_by": "jsmith" },
{ "id": 10, "metric_id": "2.1.1", "note": "...", "group_id": "legacy-10", "created_at": "...", "created_by": "admin" }
]
}
```
The frontend groups consecutive notes by `group_id` to render multi-metric notes as a single card.
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Select All / Deselect All round-trip
*For any* set of active metrics with size > 1, clicking "Select All" should result in all metrics being selected, and then clicking "Deselect All" should result in only the first metric remaining selected (minimum selection invariant).
**Validates: Requirements 2.1, 2.2**
### Property 2: Toggle label reflects selection state
*For any* set of active metrics, if the user manually selects every metric one by one, the toggle label should read "Deselect All" — the label is a pure function of whether all metrics are selected, regardless of how that state was reached.
**Validates: Requirements 2.3**
### Property 3: Multi-metric submission creates correct rows with shared group_id
*For any* valid hostname, non-empty note text, and non-empty array of valid metric IDs, submitting a note should create exactly N rows in `compliance_notes` (where N = length of the metric IDs array), all sharing the same `note` text, `created_by` user, `created_at` timestamp, and `group_id` value.
**Validates: Requirements 3.1, 3.2, 5.3, 5.7, 6.1**
### Property 4: Whitespace-only notes are rejected
*For any* string composed entirely of whitespace characters (spaces, tabs, newlines, or combinations thereof), the Notes API should reject the submission with a 400 error and create zero rows in the database.
**Validates: Requirements 3.3**
### Property 5: Atomic validation — invalid metric IDs reject the entire batch
*For any* array of metric IDs where at least one entry is invalid (empty string, exceeds 50 characters, or non-string), the Notes API should reject the entire request with a 400 error and insert zero rows, even if all other entries are valid.
**Validates: Requirements 5.2, 5.6**
### Property 6: Note grouping display
*For any* set of notes where multiple notes share the same `group_id`, the Detail Panel should render them as a single note entry displaying all associated Metric Chips, rather than as separate entries.
**Validates: Requirements 4.1, 4.2, 6.4**
### Property 7: Reverse chronological note ordering
*For any* set of notes with varying `created_at` timestamps and group sizes, the Detail Panel should display note groups in reverse chronological order (newest `created_at` first), regardless of how many metrics each group covers.
**Validates: Requirements 4.3**
## Error Handling
### Backend
| Scenario | Response | Behavior |
|---|---|---|
| Empty or whitespace-only note text | 400 `{ error: "Note cannot be empty" }` | No rows inserted |
| `metric_ids` is empty array | 400 `{ error: "At least one metric ID is required" }` | No rows inserted |
| Any metric ID in array is empty or >50 chars | 400 `{ error: "Invalid metric_id at index N" }` | No rows inserted (atomic rejection) |
| `metric_ids` is not an array (when provided) | 400 `{ error: "metric_ids must be an array" }` | Falls back to checking `metric_id` |
| Neither `metric_id` nor `metric_ids` provided | 400 `{ error: "metric_id or metric_ids is required" }` | No rows inserted |
| Database error during transaction | 500 `{ error: "Failed to save note" }` | Transaction rolled back, no partial inserts |
| Invalid hostname format | 400 `{ error: "Invalid hostname format" }` | No rows inserted (unchanged) |
Transaction safety: all inserts for a multi-metric note happen inside `BEGIN TRANSACTION` / `COMMIT`. If any insert fails, the transaction is rolled back and no rows are persisted.
### Frontend
| Scenario | Behavior |
|---|---|
| API returns 400 validation error | Display error message below the note input (existing `noteError` state) |
| API returns 500 server error | Display error message below the note input |
| Network failure | Display "Failed to save note" error |
| No metrics selected | Submit button is disabled, no API call made |
| Successful submission | Clear note text, refresh notes list, retain metric selection |
## Testing Strategy
### Unit Tests (example-based)
- **Backend:**
- Legacy `metric_id` field still creates a single note row (backward compatibility)
- Both `metric_id` and `metric_ids` provided — `metric_ids` takes precedence
- Single active metric pre-selects and is non-removable
- Response shape includes all created rows with `group_id` and `username`
- **Frontend:**
- MetricChipSelector renders correct number of chips for given active metrics
- Clicking a chip toggles its selection state
- Submit button disabled when note text is empty or no metrics selected
- Notes without `group_id` (legacy) render as individual entries
- Single active metric auto-selects and hides Select All toggle
### Property-Based Tests
Property-based tests use `fast-check` (JavaScript PBT library) with a minimum of 100 iterations per property.
Each property test is tagged with a comment referencing the design property:
- **Feature: compliance-multi-metric-notes, Property 3: Multi-metric submission creates correct rows with shared group_id**
- **Feature: compliance-multi-metric-notes, Property 4: Whitespace-only notes are rejected**
- **Feature: compliance-multi-metric-notes, Property 5: Atomic validation — invalid metric IDs reject the entire batch**
Backend properties (3, 4, 5) are tested against the route handler using a test SQLite database. Frontend properties (1, 2, 6, 7) are tested against the component rendering/grouping logic using React Testing Library with generated inputs.
### Integration Tests
- End-to-end flow: open detail panel → select multiple metrics → submit note → verify grouped display
- Migration script: verify `group_id` column exists and legacy rows are backfilled
- Backward compatibility: existing `GET /items/:hostname` response includes `group_id` field on notes