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