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
16 KiB
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:
- Frontend selection state and UI (checkboxes, floating action bar)
- Frontend aggregation functions (summary/description generation)
- A new junction table and its associated insert logic (post-creation)
- A backend endpoint to record and query junction associations
Architecture
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:
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,
selectedIdsis 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)
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)
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)
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)
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:
{
"queue_item_ids": [12, 15, 18, 22]
}
Response (201):
{
"message": "Queue items linked to ticket",
"ticket_id": 42,
"linked_count": 4
}
Validation:
queue_item_idsmust 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):
{
"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
- User clicks "Create Jira Ticket" in Consolidation Modal
- Frontend sends
POST /api/jira-tickets/create-in-jirawith:summary: user-edited summarydescription: user-edited aggregated descriptioncve_id: first CVE (or null)vendor: common vendor (or null)source_context:ivanti_queue
- On 201 response, frontend sends
POST /api/jira-tickets/:id/queue-itemswith the selected queue item IDs - 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)
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_idreferencesjira_tickets(id)— no CASCADE delete (association preserved even if ticket is deleted from local DB, though this is unlikely)queue_item_idreferencesivanti_todo_queue(id)— no CASCADE delete (association preserved even if queue item is completed/deleted)
Migration: add_multi_item_jira_ticket.js
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)