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
This commit is contained in:
Jordan Ramos
2026-06-04 11:27:31 -06:00
parent 8ebd7e4d5e
commit a61d254ff9
54 changed files with 6992 additions and 59 deletions

View File

@@ -0,0 +1 @@
{"specId": "0b0784a9-768e-42f0-ab8f-70b799b7ab67", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,408 @@
# 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)

View File

@@ -0,0 +1,114 @@
# Requirements Document
## Introduction
The STEAM Security Dashboard currently allows users to create a Jira ticket from a single Ivanti queue item via the "Create Jira Ticket" action (implemented in the `flexible-jira-ticket-creation` spec). In practice, multiple queue items often relate to the same remediation effort — same vendor, same CVE, or same host group. Users need the ability to select multiple Ivanti queue items and create ONE consolidated Jira ticket whose summary and description aggregate information from all selected items. This is not batch creation (one ticket per item) — it is consolidation (N items into 1 ticket).
## Glossary
- **Ivanti_Queue_Page**: The frontend page where users work through their Ivanti finding queue items.
- **Queue_Item**: A single row in the `ivanti_todo_queue` table representing a finding assigned to a user for triage.
- **Selection_Mode**: A UI state on the Ivanti_Queue_Page where checkboxes appear on each Queue_Item, allowing the user to select multiple items.
- **Consolidation_Modal**: The frontend modal dialog used to create a single Jira ticket from multiple selected Queue_Items, extending the existing Creation_Modal with aggregation logic.
- **Ticket_Creation_Service**: The backend endpoint (`POST /api/jira-tickets/create-in-jira`) responsible for creating issues in Jira and storing the local record.
- **Aggregated_Description**: A structured text block composed from the selected Queue_Items' metadata (finding titles, CVEs, hostnames, IP addresses, vendor) formatted for the Jira issue description.
- **Dashboard**: The STEAM Security Dashboard application.
## Requirements
### Requirement 1: Multi-Select Mode on Ivanti Queue
**User Story:** As a security analyst, I want to select multiple items from my Ivanti queue, so that I can act on them as a group.
#### Acceptance Criteria
1. THE Ivanti_Queue_Page SHALL display a "Select" toggle button that activates Selection_Mode.
2. WHILE Selection_Mode is active, THE Ivanti_Queue_Page SHALL display a checkbox on each Queue_Item row.
3. WHILE Selection_Mode is active, THE Ivanti_Queue_Page SHALL display a selection count indicator showing the number of currently selected items.
4. WHILE Selection_Mode is active, THE Ivanti_Queue_Page SHALL display a "Select All" checkbox in the table header that toggles selection of all visible Queue_Items.
5. WHEN the user deactivates Selection_Mode, THE Ivanti_Queue_Page SHALL clear all selections and hide the checkboxes.
6. THE Ivanti_Queue_Page SHALL preserve the selected items when the user scrolls or when the list re-renders due to filter changes within the same session.
### Requirement 2: Create Consolidated Jira Ticket Action
**User Story:** As a security analyst, I want to create a single Jira ticket from my selected queue items, so that I can track a group of related findings as one work item.
#### Acceptance Criteria
1. WHILE Selection_Mode is active and at least one Queue_Item is selected, THE Ivanti_Queue_Page SHALL display a "Create Jira Ticket" action button in a floating action bar.
2. WHEN no Queue_Items are selected, THE Ivanti_Queue_Page SHALL disable the "Create Jira Ticket" action button.
3. WHEN the user activates the "Create Jira Ticket" action with multiple items selected, THE Ivanti_Queue_Page SHALL open the Consolidation_Modal.
4. WHEN the user activates the "Create Jira Ticket" action with exactly one item selected, THE Ivanti_Queue_Page SHALL open the existing single-item Creation_Modal with pre-populated fields from that item (existing behavior from flexible-jira-ticket-creation spec).
### Requirement 3: Aggregated Summary Generation
**User Story:** As a security analyst, I want the consolidated ticket summary to clearly indicate it covers multiple findings, so that I can identify multi-item tickets at a glance.
#### Acceptance Criteria
1. WHEN the Consolidation_Modal opens, THE Consolidation_Modal SHALL pre-populate the Summary field with a generated value in the format: "[N findings] vendor_name - first_finding_title" where N is the count of selected items, vendor_name is the common vendor (if all items share the same vendor) or "Multiple Vendors", and first_finding_title is the finding_title of the first selected item.
2. THE Consolidation_Modal SHALL truncate the generated summary to 255 characters.
3. THE Consolidation_Modal SHALL allow the user to edit the pre-populated summary before submission.
4. IF the user clears the summary field and attempts to submit, THEN THE Consolidation_Modal SHALL prevent submission and display an inline error indicating that Summary is required.
### Requirement 4: Aggregated Description Generation
**User Story:** As a security analyst, I want the consolidated ticket description to list details from each selected item, so that the Jira ticket contains all relevant finding information.
#### Acceptance Criteria
1. WHEN the Consolidation_Modal opens, THE Consolidation_Modal SHALL generate an Aggregated_Description containing a structured list of all selected Queue_Items.
2. THE Aggregated_Description SHALL include for each Queue_Item: finding_title, CVE IDs (from cves_json), hostname, IP address, and vendor.
3. THE Aggregated_Description SHALL group items by vendor when multiple vendors are present.
4. THE Aggregated_Description SHALL include a header line stating the total count of findings included.
5. THE Consolidation_Modal SHALL display the Aggregated_Description in an editable text area, allowing the user to modify it before submission.
6. THE Consolidation_Modal SHALL pre-populate the CVE ID field with the first CVE found across all selected items (from the first item's cves_json array), or leave it blank if no items have CVEs.
7. THE Consolidation_Modal SHALL pre-populate the Vendor field with the common vendor if all selected items share the same vendor, or leave it blank if vendors differ.
### Requirement 5: Consolidated Ticket Creation via Backend
**User Story:** As a security analyst, I want the consolidated Jira ticket to be created through the existing ticket creation endpoint, so that it is tracked and synced like any other Jira ticket.
#### Acceptance Criteria
1. WHEN the user submits the Consolidation_Modal, THE Dashboard SHALL send a create-ticket request to the Ticket_Creation_Service with source_context set to `ivanti_queue`, the user-provided summary, and the Aggregated_Description as the description field.
2. THE Ticket_Creation_Service SHALL accept the request and create the Jira issue using the existing creation flow without modification to the backend endpoint contract.
3. WHEN the Jira ticket is created successfully, THE Dashboard SHALL store a local record linking the ticket to the selected Queue_Item IDs via a new junction table.
4. WHEN the Jira ticket is created successfully, THE Dashboard SHALL display a success notification with the created ticket key and a link to the Jira issue.
5. IF the Jira API returns an error, THEN THE Consolidation_Modal SHALL display an error message and preserve all form field values for retry.
### Requirement 6: Queue Item to Ticket Association
**User Story:** As a security analyst, I want to see which Jira ticket was created from my queue items, so that I can track the relationship between findings and tickets.
#### Acceptance Criteria
1. THE Dashboard SHALL include a `jira_ticket_queue_items` junction table that associates a jira_ticket ID with one or more Queue_Item IDs.
2. WHEN a consolidated Jira ticket is created, THE Dashboard SHALL insert one row per selected Queue_Item into the junction table.
3. WHILE viewing a Queue_Item that has an associated Jira ticket, THE Ivanti_Queue_Page SHALL display the linked ticket key as a badge or indicator on that item's row.
4. WHEN the user clicks the ticket key indicator on a Queue_Item, THE Ivanti_Queue_Page SHALL navigate to or open the Jira ticket URL in a new tab.
5. THE junction table association SHALL persist regardless of whether the Queue_Item is later marked complete or deleted.
### Requirement 7: Database Migration for Junction Table
**User Story:** As a database administrator, I want the queue-item-to-ticket association stored in a proper junction table, so that the relationship is queryable and maintainable.
#### Acceptance Criteria
1. THE Dashboard SHALL include a migration that creates a `jira_ticket_queue_items` table with columns: 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)), and created_at (TIMESTAMPTZ DEFAULT NOW()).
2. THE migration SHALL add a unique constraint on (jira_ticket_id, queue_item_id) to prevent duplicate associations.
3. THE migration SHALL add an index on queue_item_id for efficient lookup of tickets associated with a given queue item.
4. THE migration SHALL be idempotent — running it multiple times SHALL produce the same schema state without raising errors.
5. IF the `jira_tickets` or `ivanti_todo_queue` tables do not exist when the migration runs, THEN THE migration SHALL exit with an error message indicating the prerequisite tables are missing.
### Requirement 8: Consolidation Modal Source Context and Field Locking
**User Story:** As a security analyst, I want the consolidated ticket to automatically track its origin as the Ivanti queue, so that I do not need to manually set the source context.
#### Acceptance Criteria
1. WHEN the Consolidation_Modal opens, THE Consolidation_Modal SHALL set source_context to `ivanti_queue` and display it as read-only.
2. THE Consolidation_Modal SHALL display the count of selected items in the modal header or subtitle.
3. WHEN the Consolidation_Modal opens, THE Consolidation_Modal SHALL display a scrollable preview list of the selected Queue_Items showing finding_title and hostname for each.
4. THE Consolidation_Modal SHALL allow the user to remove individual items from the selection within the modal before submission, with a minimum of 2 items required for consolidation.
5. IF the user removes items until only 1 remains, THEN THE Consolidation_Modal SHALL display a message indicating that at least 2 items are required for consolidation and disable the submit button.

View File

@@ -0,0 +1,124 @@
# Implementation Plan: Multi-Item Jira Ticket Creation
## Overview
This plan implements multi-select on the Ivanti Queue page and a consolidation modal that creates a single Jira ticket from multiple selected queue items. The implementation proceeds from database migration → backend junction endpoints → frontend aggregation logic → frontend selection UI → consolidation modal → ticket link badges.
## Tasks
- [x] 1. Database migration for junction table
- [x] 1.1 Create migration file `backend/migrations/add_multi_item_jira_ticket.js`
- Verify `jira_tickets` and `ivanti_todo_queue` tables exist; exit with error if missing
- Create `jira_ticket_queue_items` table with columns: 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())
- Add UNIQUE constraint on (jira_ticket_id, queue_item_id)
- Add index on queue_item_id
- Add index on jira_ticket_id
- Ensure full idempotency — safe to run multiple times
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
- [x] 2. Backend junction table endpoints
- [x] 2.1 Add `POST /api/jira-tickets/:id/queue-items` endpoint in `backend/routes/jiraTickets.js`
- Validate `queue_item_ids` is a non-empty array of integers
- Verify the jira_ticket exists
- Verify all referenced queue items exist
- Insert rows into `jira_ticket_queue_items` with ON CONFLICT DO NOTHING
- Return 201 with linked_count
- Require Admin or Standard_User group
- _Requirements: 5.3, 6.1, 6.2_
- [x] 2.2 Add `GET /api/ivanti/todo-queue/ticket-links` endpoint in `backend/routes/ivantiTodoQueue.js`
- Join `jira_ticket_queue_items` with `jira_tickets` to get ticket_key and url
- Filter by queue items belonging to the authenticated user
- Return a map of queue_item_id → { ticket_key, jira_url }
- _Requirements: 6.3, 6.4_
- [x] 3. Frontend aggregation utility functions
- [x] 3.1 Create `frontend/src/utils/jiraConsolidation.js` with pure functions
- `generateConsolidatedSummary(items)` — format: `[N findings] vendor - title`, truncated to 255 chars
- `generateConsolidatedDescription(items)` — structured description grouped by vendor
- `extractFirstCve(items)` — first CVE from first item with non-empty cves_json
- `extractCommonVendor(items)` — common vendor if all same, empty string otherwise
- _Requirements: 3.1, 3.2, 4.1, 4.2, 4.3, 4.4, 4.6, 4.7_
- [ ]* 3.2 Write property tests for aggregation functions (Properties 16)
- **Property 1: Summary format and truncation** — starts with `[N findings]`, at most 255 chars, contains correct vendor label
- **Property 2: Description includes all items** — every item's finding_title appears in output
- **Property 3: Description groups by vendor** — items with same vendor are contiguous
- **Property 4: Description header contains count** — output contains the item count
- **Property 5: First CVE extraction** — returns first CVE from first item with CVEs, or empty string
- **Property 6: Common vendor extraction** — returns vendor when all same, empty when different
- **Validates: Requirements 3.1, 3.2, 4.1, 4.2, 4.3, 4.4, 4.6, 4.7**
- File: `backend/__tests__/jira-consolidation-aggregation.property.test.js`
- [x] 4. Frontend multi-select mode on Ivanti Queue page
- [x] 4.1 Add selection mode toggle and checkbox UI to `frontend/src/components/pages/IvantiQueuePage.js`
- Add "Select" toggle button to page toolbar
- Show checkboxes on each queue item row when selection mode active
- Add "Select All" checkbox in table header
- Display selection count indicator
- Clear selections when selection mode deactivated
- Preserve selections on scroll/re-render within same session
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_
- [x] 4.2 Add floating action bar with "Create Jira Ticket" button
- Render floating bar when selection mode active and at least 1 item selected
- Disable "Create Jira Ticket" button when no items selected
- Route to existing single-item modal when exactly 1 item selected
- Route to new Consolidation Modal when 2+ items selected
- _Requirements: 2.1, 2.2, 2.3, 2.4_
- [x] 5. Frontend Consolidation Modal
- [x] 5.1 Create `frontend/src/components/ConsolidationModal.js`
- Accept selected queue items as props
- Call aggregation functions to pre-populate summary, description, cve_id, vendor
- Lock source_context to `ivanti_queue` (read-only display)
- Display item count in modal header/subtitle
- Render scrollable preview list of selected items (finding_title + hostname)
- Allow removing individual items from selection (minimum 2 required)
- Disable submit and show message when fewer than 2 items remain
- Editable summary field with required validation (max 255 chars)
- Editable description textarea
- Editable CVE ID and Vendor fields (optional)
- On submit: POST to create-in-jira, then POST to junction endpoint
- On success: close modal, show success toast, trigger queue page refresh
- On error: display error message, preserve form values
- _Requirements: 3.1, 3.2, 3.3, 3.4, 4.1, 4.5, 5.1, 5.2, 5.4, 5.5, 8.1, 8.2, 8.3, 8.4, 8.5_
- [x] 6. Frontend ticket link badges on queue items
- [x] 6.1 Fetch and display ticket link badges on Ivanti Queue page
- On page load, fetch `GET /api/ivanti/todo-queue/ticket-links`
- For each queue item with an association, render a ticket key badge (e.g., "VULN-789")
- Clicking badge opens Jira URL in new tab
- Refresh links after successful consolidated ticket creation
- _Requirements: 6.3, 6.4, 6.5_
- [ ] 7. Backend property test for junction insert invariant
- [ ]* 7.1 Write property test for junction row count (Property 7)
- **Property 7: Junction table row count equals selected item count** — for N items, exactly N rows inserted
- **Validates: Requirements 5.3, 6.2**
- File: `backend/__tests__/jira-consolidation-junction.property.test.js`
- [x] 8. Final checkpoint
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional property-based tests that can be skipped for faster MVP
- The existing `POST /api/jira-tickets/create-in-jira` endpoint is reused without modification
- The aggregation functions are pure and extracted into a utility module for easy testing
- The junction table insert happens as a second request after ticket creation — if it fails, the ticket still exists in Jira (partial success scenario handled with warning)
- The Consolidation Modal is a separate component from the existing Creation Modal to avoid overcomplicating the single-item flow
## Task Dependency Graph
```json
{
"waves": [
{ "id": 0, "tasks": ["1.1"] },
{ "id": 1, "tasks": ["2.1", "2.2", "3.1"] },
{ "id": 2, "tasks": ["3.2", "4.1"] },
{ "id": 3, "tasks": ["4.2", "5.1"] },
{ "id": 4, "tasks": ["6.1", "7.1"] }
]
}
```