Files
cve-dashboard/.kiro/specs/multi-item-jira-ticket/design.md
Jordan Ramos a61d254ff9 Sync .kiro/ from master — v2.2.0 release batch
New specs: archer-template-library, ccp-metrics-view-restructure,
compliance-list-stale-after-sidebar-edit, compliance-metric-estimated-resolution-date,
compliance-remediation-display-fix, flexible-jira-ticket-creation,
forecast-burndown-chart, granite-loader-export, ivanti-queue-clear-completed-fix,
multi-item-jira-ticket, queue-collapsible-sections, vendor-issue-type-dropdown

New steering: archer-template-gen.md

Updated: migration-registration-check hook, remediation-plan-history spec,
gitlab-workflow, tech, versioning steering files
2026-06-04 11:27:31 -06:00

409 lines
16 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-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)