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

16 KiB
Raw Blame 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

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, 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)

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_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):

{
    "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

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_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

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)