# 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
Selection Mode + Action Bar] ConsolidationModal[Consolidation Modal
Aggregated Summary/Description] SingleModal[Existing Creation Modal
Single-item flow] end subgraph Backend CreateEndpoint[POST /api/jira-tickets/create-in-jira
Unchanged contract] JunctionEndpoint[POST /api/jira-tickets/:id/queue-items
New: store associations] GetAssocEndpoint[GET /api/ivanti/todo-queue/ticket-links
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 | 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 1–6 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)