291 lines
14 KiB
Markdown
291 lines
14 KiB
Markdown
# 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, 1–300 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
|