Files
cve-dashboard/.kiro/specs/multi-item-jira-ticket/design.md

409 lines
16 KiB
Markdown
Raw Normal View History

# Design Document: Multi-Item Jira Ticket Creation
## Overview
This feature adds multi-select capability to the Ivanti Queue page and a consolidation modal that aggregates information from multiple selected queue items into a single Jira ticket. The design builds on the existing `flexible-jira-ticket-creation` infrastructure — reusing the same backend endpoint (`POST /api/jira-tickets/create-in-jira`) and extending the frontend with selection state management, aggregation logic, and a junction table for tracking which queue items contributed to a ticket.
The backend endpoint contract remains unchanged. All new logic lives in:
1. Frontend selection state and UI (checkboxes, floating action bar)
2. Frontend aggregation functions (summary/description generation)
3. A new junction table and its associated insert logic (post-creation)
4. A backend endpoint to record and query junction associations
## Architecture
```mermaid
flowchart TD
subgraph Frontend
QueuePage[Ivanti Queue Page<br/>Selection Mode + Action Bar]
ConsolidationModal[Consolidation Modal<br/>Aggregated Summary/Description]
SingleModal[Existing Creation Modal<br/>Single-item flow]
end
subgraph Backend
CreateEndpoint[POST /api/jira-tickets/create-in-jira<br/>Unchanged contract]
JunctionEndpoint[POST /api/jira-tickets/:id/queue-items<br/>New: store associations]
GetAssocEndpoint[GET /api/ivanti/todo-queue/ticket-links<br/>New: fetch linked tickets]
JiraAPI[jiraApi.js → Jira REST API]
DB[(PostgreSQL)]
end
QueuePage -->|1 item selected| SingleModal
QueuePage -->|2+ items selected| ConsolidationModal
ConsolidationModal -->|POST create ticket| CreateEndpoint
CreateEndpoint --> JiraAPI
JiraAPI -->|issue created| DB
ConsolidationModal -->|POST associations| JunctionEndpoint
JunctionEndpoint --> DB
QueuePage -->|fetch linked tickets| GetAssocEndpoint
GetAssocEndpoint --> DB
```
## Components and Interfaces
### Frontend: Selection State Management
Selection state is managed in the Ivanti Queue Page component using React state:
```javascript
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState(new Set());
```
**Selection Mode Toggle:**
- A "Select" button in the page toolbar toggles `selectionMode`
- When deactivated, `selectedIds` is cleared
**Select All:**
- A header checkbox toggles all currently visible (filtered) queue item IDs into/out of `selectedIds`
**Floating Action Bar:**
- Rendered when `selectionMode === true && selectedIds.size > 0`
- Contains: selection count badge, "Create Jira Ticket" button, "Cancel" button
### Frontend: Consolidation Modal
The Consolidation Modal is a new component that receives the selected queue items as props and handles aggregation.
**Props:**
| Prop | Type | Description |
|---|---|---|
| `items` | Array<QueueItem> | The selected queue items (full objects) |
| `onClose` | Function | Close handler |
| `onSuccess` | Function | Called with created ticket data on success |
**Internal State:**
| Field | Initial Value | Editable |
|---|---|---|
| `summary` | Generated (see aggregation) | Yes |
| `description` | Generated (see aggregation) | Yes |
| `cve_id` | First CVE from items | Yes |
| `vendor` | Common vendor or empty | Yes |
| `source_context` | `ivanti_queue` | No (locked) |
| `selectedItems` | Copy of `items` prop | Yes (removable) |
### Frontend: Aggregation Functions (Pure)
These are pure functions that can be unit tested and property tested independently.
**`generateConsolidatedSummary(items)`**
```javascript
function generateConsolidatedSummary(items) {
const count = items.length;
const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
const vendorLabel = vendors.length === 1 ? vendors[0] : 'Multiple Vendors';
const firstTitle = items[0]?.finding_title || 'Untitled';
const raw = `[${count} findings] ${vendorLabel} - ${firstTitle}`;
return raw.slice(0, 255);
}
```
**`generateConsolidatedDescription(items)`**
```javascript
function generateConsolidatedDescription(items) {
const header = `Consolidated Jira ticket covering ${items.length} Ivanti queue findings.\n\n`;
// Group by vendor
const grouped = {};
for (const item of items) {
const vendor = item.vendor || 'Unknown Vendor';
if (!grouped[vendor]) grouped[vendor] = [];
grouped[vendor].push(item);
}
let body = '';
for (const [vendor, vendorItems] of Object.entries(grouped)) {
body += `== ${vendor} ==\n`;
for (const item of vendorItems) {
const cves = item.cves_json ? JSON.parse(item.cves_json).join(', ') : 'None';
body += `- ${item.finding_title}\n`;
body += ` CVEs: ${cves}\n`;
body += ` Host: ${item.hostname || 'N/A'} (${item.ip_address || 'N/A'})\n\n`;
}
}
return header + body;
}
```
**`extractFirstCve(items)`**
```javascript
function extractFirstCve(items) {
for (const item of items) {
if (item.cves_json) {
const cves = JSON.parse(item.cves_json);
if (Array.isArray(cves) && cves.length > 0) return cves[0];
}
}
return '';
}
```
**`extractCommonVendor(items)`**
```javascript
function extractCommonVendor(items) {
const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
return vendors.length === 1 ? vendors[0] : '';
}
```
### Backend: Junction Table Endpoints (New)
**`POST /api/jira-tickets/:id/queue-items`**
Records the association between a Jira ticket and the queue items that contributed to it.
| Field | Type | Required | Description |
|---|---|---|---|
| `queue_item_ids` | number[] | Yes | Array of ivanti_todo_queue IDs |
**Request:**
```json
{
"queue_item_ids": [12, 15, 18, 22]
}
```
**Response (201):**
```json
{
"message": "Queue items linked to ticket",
"ticket_id": 42,
"linked_count": 4
}
```
**Validation:**
- `queue_item_ids` must be a non-empty array of integers
- All referenced queue items must exist
- The jira_ticket must exist
- Duplicate associations are ignored (ON CONFLICT DO NOTHING)
**`GET /api/ivanti/todo-queue/ticket-links`**
Returns ticket associations for the current user's queue items.
**Response (200):**
```json
{
"links": {
"12": { "ticket_key": "VULN-789", "jira_url": "https://jira.example.com/browse/VULN-789" },
"15": { "ticket_key": "VULN-789", "jira_url": "https://jira.example.com/browse/VULN-789" },
"22": { "ticket_key": "VULN-801", "jira_url": "https://jira.example.com/browse/VULN-801" }
}
}
```
Returns a map of queue_item_id → ticket info for all queue items belonging to the authenticated user that have an associated Jira ticket.
### Frontend: Submission Flow
1. User clicks "Create Jira Ticket" in Consolidation Modal
2. Frontend sends `POST /api/jira-tickets/create-in-jira` with:
- `summary`: user-edited summary
- `description`: user-edited aggregated description
- `cve_id`: first CVE (or null)
- `vendor`: common vendor (or null)
- `source_context`: `ivanti_queue`
3. On 201 response, frontend sends `POST /api/jira-tickets/:id/queue-items` with the selected queue item IDs
4. On success, modal closes, success toast shown, queue page refreshes ticket link badges
### Frontend: Ticket Link Badge on Queue Items
When the queue page loads, it fetches `GET /api/ivanti/todo-queue/ticket-links` and stores the result in state. For each queue item row, if a link exists, a small badge (e.g., `VULN-789`) is rendered. Clicking the badge opens the Jira URL in a new tab.
## Data Models
### `jira_ticket_queue_items` Table (New)
```sql
CREATE TABLE IF NOT EXISTS jira_ticket_queue_items (
id SERIAL PRIMARY KEY,
jira_ticket_id INTEGER NOT NULL REFERENCES jira_tickets(id),
queue_item_id INTEGER NOT NULL REFERENCES ivanti_todo_queue(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (jira_ticket_id, queue_item_id)
);
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_queue_item
ON jira_ticket_queue_items(queue_item_id);
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_ticket
ON jira_ticket_queue_items(jira_ticket_id);
```
**Foreign Key Behavior:**
- `jira_ticket_id` references `jira_tickets(id)` — no CASCADE delete (association preserved even if ticket is deleted from local DB, though this is unlikely)
- `queue_item_id` references `ivanti_todo_queue(id)` — no CASCADE delete (association preserved even if queue item is completed/deleted)
### Migration: `add_multi_item_jira_ticket.js`
```javascript
const pool = require('../db');
async function run() {
console.log('Starting multi-item Jira ticket migration...');
// Verify prerequisite tables exist
const { rows: jiraTable } = await pool.query(`
SELECT 1 FROM information_schema.tables WHERE table_name = 'jira_tickets'
`);
if (jiraTable.length === 0) {
console.error('✗ jira_tickets table does not exist. Cannot proceed.');
process.exit(1);
}
const { rows: queueTable } = await pool.query(`
SELECT 1 FROM information_schema.tables WHERE table_name = 'ivanti_todo_queue'
`);
if (queueTable.length === 0) {
console.error('✗ ivanti_todo_queue table does not exist. Cannot proceed.');
process.exit(1);
}
// Create junction table
await pool.query(`
CREATE TABLE IF NOT EXISTS jira_ticket_queue_items (
id SERIAL PRIMARY KEY,
jira_ticket_id INTEGER NOT NULL REFERENCES jira_tickets(id),
queue_item_id INTEGER NOT NULL REFERENCES ivanti_todo_queue(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (jira_ticket_id, queue_item_id)
)
`);
console.log('✓ jira_ticket_queue_items table created (or already exists)');
// Add indexes
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_queue_item
ON jira_ticket_queue_items(queue_item_id)
`);
console.log('✓ queue_item_id index created (or already exists)');
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_ticket
ON jira_ticket_queue_items(jira_ticket_id)
`);
console.log('✓ jira_ticket_id index created (or already exists)');
console.log('Migration complete.');
process.exit(0);
}
run().catch(err => {
console.error('Migration failed:', err.message);
process.exit(1);
});
```
## Correctness Properties
### Property 1: Summary generation format and truncation
*For any* non-empty array of queue items (each with optional vendor and finding_title of arbitrary length), `generateConsolidatedSummary` SHALL return a string that:
- Starts with `[N findings]` where N equals the array length
- Is at most 255 characters long
- Contains the common vendor name when all items share the same vendor, or "Multiple Vendors" when vendors differ
**Validates: Requirements 3.1, 3.2**
### Property 2: Description includes all items
*For any* non-empty array of queue items, `generateConsolidatedDescription` SHALL produce a string that contains the `finding_title` of every item in the input array.
**Validates: Requirements 4.1, 4.2**
### Property 3: Description groups by vendor
*For any* array of queue items with two or more distinct vendors, `generateConsolidatedDescription` SHALL produce a string where all items sharing the same vendor appear in a contiguous block (not interleaved with items from other vendors).
**Validates: Requirements 4.3**
### Property 4: Description header contains item count
*For any* non-empty array of queue items of length N, `generateConsolidatedDescription` SHALL produce a string containing the substring representing N (the count of items).
**Validates: Requirements 4.4**
### Property 5: First CVE extraction
*For any* array of queue items, `extractFirstCve` SHALL return the first element of the first non-empty `cves_json` array encountered in item order, or an empty string if no items have CVEs.
**Validates: Requirements 4.6**
### Property 6: Common vendor extraction
*For any* array of queue items where all items have the same non-null vendor value, `extractCommonVendor` SHALL return that vendor value. For any array where items have two or more distinct vendor values, `extractCommonVendor` SHALL return an empty string.
**Validates: Requirements 4.7**
### Property 7: Junction table row count equals selected item count
*For any* successful consolidated ticket creation with N selected queue items (N >= 2), the `jira_ticket_queue_items` table SHALL contain exactly N rows with the created ticket's ID.
**Validates: Requirements 5.3, 6.2**
## Error Handling
| Scenario | HTTP Status | Error Message | Client Behavior |
|---|---|---|---|
| No queue items selected | N/A (frontend) | "Select at least one item" | Button disabled, no request sent |
| Fewer than 2 items in consolidation modal | N/A (frontend) | "At least 2 items required for consolidation" | Submit button disabled |
| Empty summary on submit | 400 | "Summary is required (max 255 chars)." | Inline error, preserve form |
| Jira API unavailable | 502 | "Failed to create Jira issue." | Error banner, preserve form for retry |
| Jira rate limit exceeded | 429 | "Jira rate limit exceeded. Try again later." | Error banner, preserve form |
| Junction insert fails after ticket creation | 207 (partial) | Warning with ticket key/URL | Show ticket link, warn that associations failed |
| Invalid queue_item_ids in junction request | 400 | "queue_item_ids must be a non-empty array of integers" | Display error |
| Referenced queue items do not exist | 400 | "One or more queue items not found" | Display error |
| Prerequisite tables missing (migration) | Exit code 1 | Console error | Migration aborts cleanly |
## Testing Strategy
### Unit Tests (Example-Based)
- Selection mode toggle shows/hides checkboxes
- Select All selects all visible items
- Deactivating selection mode clears selections
- Floating action bar appears when items selected, hidden when none selected
- "Create Jira Ticket" opens consolidation modal for 2+ items
- "Create Jira Ticket" opens single-item modal for exactly 1 item
- Consolidation modal renders with correct initial summary, description, CVE, vendor
- Consolidation modal allows editing summary and description
- Consolidation modal prevents submission with empty summary
- Consolidation modal source_context is read-only and set to ivanti_queue
- Removing items from modal updates preview list and regenerates summary/description
- Removing items below 2 disables submit
- Ticket link badge renders on queue items with associations
- Clicking ticket link badge opens URL in new tab
- Junction endpoint rejects empty or non-array queue_item_ids
- Junction endpoint rejects non-existent queue item IDs
- Migration creates table with correct schema
- Migration is idempotent (running twice succeeds)
- Migration fails gracefully when prerequisite tables missing
### Property-Based Tests
Property-based testing is appropriate for the aggregation functions because they operate on arrays of arbitrary length with arbitrary string content — a wide input space where universal format properties must hold.
**Library:** fast-check (already available in the project's test infrastructure via Jest)
**Configuration:**
- Minimum 100 iterations per property test
- Each test tagged with: `Feature: multi-item-jira-ticket, Property {N}: {title}`
Properties 16 test pure aggregation functions (frontend logic extracted into testable modules).
Property 7 tests the backend junction insert invariant (mockable DB).
### Integration Tests
- End-to-end: select 3 items → create consolidated ticket → verify junction table has 3 rows
- End-to-end: verify ticket-links endpoint returns correct associations after creation
- Migration runs successfully on database with existing jira_tickets and ivanti_todo_queue rows
- Unique constraint prevents duplicate junction entries (ON CONFLICT DO NOTHING)