From 6b805ee633b38007b6570fdc7dbc83430a7be31d Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Fri, 22 May 2026 11:12:45 -0600 Subject: [PATCH] Add multi-item Jira ticket creation from Ivanti Queue Select multiple queue items and create a single consolidated Jira ticket with aggregated summary and description. Adds multi-select mode with checkboxes, floating action bar, consolidation modal, and junction table to track which queue items contributed to each ticket. - Migration: jira_ticket_queue_items junction table - POST /api/jira-tickets/:id/queue-items endpoint - GET /api/ivanti/todo-queue/ticket-links endpoint - ConsolidationModal component with aggregation logic - IvantiTodoQueuePage with selection mode and ticket link badges - Pure utility functions for summary/description generation - 34 tests passing (backend + frontend) --- .../ivanti-todo-queue-ticket-links.test.js | 137 +++ .../__tests__/jira-ticket-queue-items.test.js | 214 +++++ .../migrations/add_multi_item_jira_ticket.js | 65 ++ backend/migrations/run-all.js | 1 + backend/routes/ivantiTodoQueue.js | 35 + backend/routes/jiraTickets.js | 84 ++ frontend/src/components/ConsolidationModal.js | 576 +++++++++++ .../components/pages/IvantiTodoQueuePage.js | 902 ++++++++++++++++++ frontend/src/utils/jiraConsolidation.js | 92 ++ frontend/src/utils/jiraConsolidation.test.js | 175 ++++ 10 files changed, 2281 insertions(+) create mode 100644 backend/__tests__/ivanti-todo-queue-ticket-links.test.js create mode 100644 backend/__tests__/jira-ticket-queue-items.test.js create mode 100644 backend/migrations/add_multi_item_jira_ticket.js create mode 100644 frontend/src/components/ConsolidationModal.js create mode 100644 frontend/src/components/pages/IvantiTodoQueuePage.js create mode 100644 frontend/src/utils/jiraConsolidation.js create mode 100644 frontend/src/utils/jiraConsolidation.test.js diff --git a/backend/__tests__/ivanti-todo-queue-ticket-links.test.js b/backend/__tests__/ivanti-todo-queue-ticket-links.test.js new file mode 100644 index 0000000..bfcc006 --- /dev/null +++ b/backend/__tests__/ivanti-todo-queue-ticket-links.test.js @@ -0,0 +1,137 @@ +/** + * Unit tests for GET /api/ivanti/todo-queue/ticket-links endpoint + * Validates: Requirements 6.3, 6.4 + */ +const http = require('http'); +const express = require('express'); + +// Mock auth middleware +jest.mock('../middleware/auth', () => ({ + requireAuth: () => (req, _res, next) => { + req.user = { id: 7, username: 'testuser' }; + next(); + }, + requireGroup: () => (_req, _res, next) => next(), +})); + +// Mock audit log +jest.mock('../helpers/auditLog', () => jest.fn()); + +// Mock the db pool +jest.mock('../db', () => ({ + query: jest.fn(() => Promise.resolve({ rows: [] })), + connect: jest.fn(), +})); + +const pool = require('../db'); +const createIvantiTodoQueueRouter = require('../routes/ivantiTodoQueue'); + +/** + * Helper: send an HTTP request and return { statusCode, body }. + */ +function request(server, method, path) { + return new Promise((resolve, reject) => { + const addr = server.address(); + const options = { + hostname: '127.0.0.1', + port: addr.port, + path, + method, + headers: { 'Content-Type': 'application/json' }, + }; + + const req = http.request(options, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString(); + let body; + try { body = JSON.parse(raw); } catch { body = raw; } + resolve({ statusCode: res.statusCode, body }); + }); + }); + + req.on('error', reject); + req.end(); + }); +} + +describe('GET /api/ivanti/todo-queue/ticket-links', () => { + let app; + let server; + + beforeAll((done) => { + app = express(); + app.use(express.json()); + app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter()); + server = app.listen(0, '127.0.0.1', done); + }); + + afterAll((done) => { + server.close(done); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns an empty links object when no associations exist', async () => { + pool.query.mockResolvedValueOnce({ rows: [] }); + + const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links'); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ links: {} }); + }); + + it('returns a map of queue_item_id to ticket info', async () => { + pool.query.mockResolvedValueOnce({ + rows: [ + { queue_item_id: 12, ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' }, + { queue_item_id: 15, ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' }, + { queue_item_id: 22, ticket_key: 'VULN-801', jira_url: 'https://jira.example.com/browse/VULN-801' }, + ], + }); + + const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links'); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ + 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' }, + }, + }); + }); + + it('filters by the authenticated user ID', async () => { + pool.query.mockResolvedValueOnce({ rows: [] }); + + await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links'); + + const [sql, params] = pool.query.mock.calls[0]; + expect(sql).toContain('q.user_id = $1'); + expect(params).toEqual([7]); + }); + + it('joins jira_ticket_queue_items with jira_tickets and ivanti_todo_queue', async () => { + pool.query.mockResolvedValueOnce({ rows: [] }); + + await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links'); + + const [sql] = pool.query.mock.calls[0]; + expect(sql).toContain('jira_ticket_queue_items'); + expect(sql).toContain('JOIN jira_tickets'); + expect(sql).toContain('JOIN ivanti_todo_queue'); + }); + + it('returns 500 on database error', async () => { + pool.query.mockRejectedValueOnce(new Error('DB connection failed')); + + const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links'); + + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: 'Internal server error.' }); + }); +}); diff --git a/backend/__tests__/jira-ticket-queue-items.test.js b/backend/__tests__/jira-ticket-queue-items.test.js new file mode 100644 index 0000000..e750217 --- /dev/null +++ b/backend/__tests__/jira-ticket-queue-items.test.js @@ -0,0 +1,214 @@ +/** + * Unit Tests: POST /api/jira-tickets/:id/queue-items + * + * Feature: multi-item-jira-ticket + * + * Tests the junction endpoint that links queue items to a Jira ticket. + * Validates: Requirements 5.3, 6.1, 6.2 + */ + +const http = require('http'); +const express = require('express'); + +// Mock the auth middleware so routes don't require real sessions/cookies. +jest.mock('../middleware/auth', () => ({ + requireAuth: () => (req, res, next) => { + req.user = { id: 1, username: 'test', group: 'Admin' }; + next(); + }, + requireGroup: (...groups) => (req, res, next) => next(), +})); + +// Mock the audit log helper to be a no-op. +jest.mock('../helpers/auditLog', () => jest.fn()); + +// Mock the jiraApi helper +jest.mock('../helpers/jiraApi', () => ({ + isConfigured: false, + getRateLimitStatus: jest.fn(() => ({ + burst: { remaining: 60, limit: 60 }, + daily: { remaining: 1440, limit: 1440 }, + })), +})); + +const pool = require('../db'); +jest.mock('../db', () => ({ + query: jest.fn(), +})); + +const createJiraTicketsRouter = require('../routes/jiraTickets'); + +/** + * Helper: send an HTTP request to the test server and return { statusCode, body }. + */ +function request(server, method, path, body) { + return new Promise((resolve, reject) => { + const addr = server.address(); + const options = { + hostname: '127.0.0.1', + port: addr.port, + path, + method, + headers: { 'Content-Type': 'application/json' }, + }; + + const req = http.request(options, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString(); + let parsed; + try { parsed = JSON.parse(raw); } catch { parsed = raw; } + resolve({ statusCode: res.statusCode, body: parsed }); + }); + }); + + req.on('error', reject); + + if (body) { + req.write(JSON.stringify(body)); + } + req.end(); + }); +} + +describe('POST /api/jira-tickets/:id/queue-items', () => { + let app; + let server; + + beforeAll((done) => { + app = express(); + app.use(express.json()); + app.use('/api/jira-tickets', createJiraTicketsRouter()); + server = app.listen(0, '127.0.0.1', done); + }); + + afterAll((done) => { + server.close(done); + }); + + beforeEach(() => { + pool.query.mockReset(); + }); + + // ------------------------------------------------------------------------- + // Validation tests + // ------------------------------------------------------------------------- + + it('returns 400 when queue_item_ids is missing', async () => { + const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {}); + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers'); + }); + + it('returns 400 when queue_item_ids is an empty array', async () => { + const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', { + queue_item_ids: [], + }); + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers'); + }); + + it('returns 400 when queue_item_ids is not an array', async () => { + const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', { + queue_item_ids: 'not-an-array', + }); + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers'); + }); + + it('returns 400 when queue_item_ids contains non-integers', async () => { + const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', { + queue_item_ids: [1, 2.5, 3], + }); + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers'); + }); + + it('returns 400 when queue_item_ids contains strings', async () => { + const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', { + queue_item_ids: [1, 'abc', 3], + }); + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers'); + }); + + // ------------------------------------------------------------------------- + // Ticket existence check + // ------------------------------------------------------------------------- + + it('returns 404 when jira ticket does not exist', async () => { + pool.query.mockResolvedValueOnce({ rows: [] }); // ticket lookup + + const res = await request(server, 'POST', '/api/jira-tickets/999/queue-items', { + queue_item_ids: [1, 2, 3], + }); + expect(res.statusCode).toBe(404); + expect(res.body.error).toBe('Jira ticket not found'); + }); + + // ------------------------------------------------------------------------- + // Queue item existence check + // ------------------------------------------------------------------------- + + it('returns 400 when some queue items do not exist', async () => { + pool.query + .mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists + .mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }] }); // only 2 of 3 exist + + const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', { + queue_item_ids: [1, 2, 3], + }); + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('One or more queue items not found'); + }); + + // ------------------------------------------------------------------------- + // Successful linking + // ------------------------------------------------------------------------- + + it('returns 201 with linked_count on success', async () => { + pool.query + .mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists + .mockResolvedValueOnce({ rows: [{ id: 12 }, { id: 15 }, { id: 18 }] }) // all queue items exist + .mockResolvedValueOnce({ rowCount: 3 }); // insert result + + const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', { + queue_item_ids: [12, 15, 18], + }); + expect(res.statusCode).toBe(201); + expect(res.body.message).toBe('Queue items linked to ticket'); + expect(res.body.ticket_id).toBe(42); + expect(res.body.linked_count).toBe(3); + }); + + it('returns linked_count reflecting ON CONFLICT DO NOTHING (duplicates ignored)', async () => { + pool.query + .mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists + .mockResolvedValueOnce({ rows: [{ id: 12 }, { id: 15 }] }) // all queue items exist + .mockResolvedValueOnce({ rowCount: 1 }); // only 1 new row (1 was duplicate) + + const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', { + queue_item_ids: [12, 15], + }); + expect(res.statusCode).toBe(201); + expect(res.body.linked_count).toBe(1); + }); + + // ------------------------------------------------------------------------- + // Error handling + // ------------------------------------------------------------------------- + + it('returns 500 on database error', async () => { + pool.query + .mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists + .mockResolvedValueOnce({ rows: [{ id: 12 }] }) // queue items exist + .mockRejectedValueOnce(new Error('Connection lost')); // insert fails + + const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', { + queue_item_ids: [12], + }); + expect(res.statusCode).toBe(500); + expect(res.body.error).toContain('Connection lost'); + }); +}); diff --git a/backend/migrations/add_multi_item_jira_ticket.js b/backend/migrations/add_multi_item_jira_ticket.js new file mode 100644 index 0000000..086104b --- /dev/null +++ b/backend/migrations/add_multi_item_jira_ticket.js @@ -0,0 +1,65 @@ +// Migration: Add multi-item Jira ticket junction table +// - Creates jira_ticket_queue_items table linking jira_tickets to ivanti_todo_queue items +// - Adds UNIQUE constraint on (jira_ticket_id, queue_item_id) +// - Adds indexes on queue_item_id and jira_ticket_id +// Idempotent — safe to run multiple times. +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_schema = 'public' AND table_name = 'jira_tickets' + `); + if (jiraTable.length === 0) { + console.error('✗ jira_tickets table does not exist. Cannot proceed.'); + process.exit(1); + } + console.log('✓ jira_tickets table exists'); + + const { rows: queueTable } = await pool.query(` + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'ivanti_todo_queue' + `); + if (queueTable.length === 0) { + console.error('✗ ivanti_todo_queue table does not exist. Cannot proceed.'); + process.exit(1); + } + console.log('✓ ivanti_todo_queue table exists'); + + // 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 index on queue_item_id for efficient lookup of tickets by queue item + 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)'); + + // Add index on jira_ticket_id for efficient lookup of queue items by ticket + 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); +}); diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js index 28ab2e6..533a49e 100644 --- a/backend/migrations/run-all.js +++ b/backend/migrations/run-all.js @@ -23,6 +23,7 @@ const POSTGRES_MIGRATIONS = [ 'add_compliance_item_history.js', 'add_jira_sync_columns_pg.js', 'add_flexible_jira_ticket_creation.js', + 'add_multi_item_jira_ticket.js', ]; async function runAll() { diff --git a/backend/routes/ivantiTodoQueue.js b/backend/routes/ivantiTodoQueue.js index 0eca663..5318350 100644 --- a/backend/routes/ivantiTodoQueue.js +++ b/backend/routes/ivantiTodoQueue.js @@ -396,6 +396,41 @@ function createIvantiTodoQueueRouter() { } }); + /** + * GET /api/ivanti/todo-queue/ticket-links + * + * Returns Jira ticket associations for the current user's queue items. + * Joins jira_ticket_queue_items with jira_tickets to get ticket_key and url. + * + * @returns {Object} { links: { [queue_item_id]: { ticket_key, jira_url } } } + * @error 500 Internal server error + */ + router.get('/ticket-links', requireAuth(), async (req, res) => { + try { + const { rows } = await pool.query( + `SELECT jtqi.queue_item_id, jt.ticket_key, jt.url AS jira_url + FROM jira_ticket_queue_items jtqi + JOIN jira_tickets jt ON jt.id = jtqi.jira_ticket_id + JOIN ivanti_todo_queue q ON q.id = jtqi.queue_item_id + WHERE q.user_id = $1`, + [req.user.id] + ); + + const links = {}; + for (const row of rows) { + links[row.queue_item_id] = { + ticket_key: row.ticket_key, + jira_url: row.jira_url + }; + } + + res.json({ links }); + } catch (err) { + console.error('Error fetching ticket links:', err); + res.status(500).json({ error: 'Internal server error.' }); + } + }); + /** * DELETE /api/ivanti/todo-queue/completed * diff --git a/backend/routes/jiraTickets.js b/backend/routes/jiraTickets.js index 1517151..bb7511a 100644 --- a/backend/routes/jiraTickets.js +++ b/backend/routes/jiraTickets.js @@ -762,6 +762,90 @@ function createJiraTicketsRouter() { } }); + // ----------------------------------------------------------------------- + // Junction table endpoint — link queue items to a Jira ticket + // ----------------------------------------------------------------------- + + /** + * POST /api/jira-tickets/:id/queue-items + * + * Records associations between a Jira ticket and Ivanti queue items that + * contributed to it. Uses ON CONFLICT DO NOTHING to handle duplicates. + * + * @param {string} id - Local Jira ticket ID (path parameter) + * @requires Admin or Standard_User group + * @body {number[]} queue_item_ids - Non-empty array of ivanti_todo_queue IDs + * @returns {object} 201 - { message, ticket_id, linked_count } + * @returns {object} 400 - { error: string } for validation failures + * @returns {object} 404 - { error: string } when ticket not found + * @returns {object} 500 - { error: string } on internal error + */ + router.post('/:id/queue-items', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { + const { id } = req.params; + const { queue_item_ids } = req.body; + + // Validate queue_item_ids is a non-empty array of integers + if (!Array.isArray(queue_item_ids) || queue_item_ids.length === 0) { + return res.status(400).json({ error: 'queue_item_ids must be a non-empty array of integers' }); + } + + for (const qid of queue_item_ids) { + if (!Number.isInteger(qid)) { + return res.status(400).json({ error: 'queue_item_ids must be a non-empty array of integers' }); + } + } + + try { + // Verify the jira_ticket exists + const { rows: ticketRows } = await pool.query( + 'SELECT id FROM jira_tickets WHERE id = $1', + [id] + ); + if (ticketRows.length === 0) { + return res.status(404).json({ error: 'Jira ticket not found' }); + } + + // Verify all referenced queue items exist + const { rows: existingItems } = await pool.query( + 'SELECT id FROM ivanti_todo_queue WHERE id = ANY($1::int[])', + [queue_item_ids] + ); + if (existingItems.length !== queue_item_ids.length) { + return res.status(400).json({ error: 'One or more queue items not found' }); + } + + // Insert rows with ON CONFLICT DO NOTHING + const values = queue_item_ids.map((qid, idx) => `($1, $${idx + 2})`).join(', '); + const params = [id, ...queue_item_ids]; + + const { rowCount } = await pool.query( + `INSERT INTO jira_ticket_queue_items (jira_ticket_id, queue_item_id) + VALUES ${values} + ON CONFLICT (jira_ticket_id, queue_item_id) DO NOTHING`, + params + ); + + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'jira_ticket_link_queue_items', + entityType: 'jira_ticket', + entityId: id, + details: { queue_item_ids, linked_count: rowCount }, + ipAddress: req.ip + }); + + res.status(201).json({ + message: 'Queue items linked to ticket', + ticket_id: parseInt(id, 10), + linked_count: rowCount + }); + } catch (err) { + console.error('Error linking queue items to Jira ticket:', err); + res.status(500).json({ error: err.message || 'Internal server error.' }); + } + }); + return router; } diff --git a/frontend/src/components/ConsolidationModal.js b/frontend/src/components/ConsolidationModal.js new file mode 100644 index 0000000..2aeaf83 --- /dev/null +++ b/frontend/src/components/ConsolidationModal.js @@ -0,0 +1,576 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { X, AlertCircle, Loader, FileText } from 'lucide-react'; +import { + generateConsolidatedSummary, + generateConsolidatedDescription, + extractFirstCve, + extractCommonVendor, +} from '../utils/jiraConsolidation'; + +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +// --------------------------------------------------------------------------- +// Styles — dark theme, monospace fonts, #0EA5E9 accent, gradient backgrounds +// --------------------------------------------------------------------------- +const STYLES = { + overlay: { + position: 'fixed', + inset: 0, + zIndex: 100, + background: 'rgba(10, 14, 39, 0.95)', + backdropFilter: 'blur(8px)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '1rem', + }, + modal: { + position: 'relative', + background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98) 0%, rgba(15, 23, 42, 0.99) 100%)', + border: '1px solid rgba(14, 165, 233, 0.25)', + borderRadius: '16px', + padding: '2rem', + width: '90%', + maxWidth: '640px', + maxHeight: '85vh', + overflowY: 'auto', + boxShadow: '0 20px 60px rgba(0,0,0,0.7), 0 0 30px rgba(14,165,233,0.08)', + }, + header: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + marginBottom: '1.25rem', + }, + title: { + fontFamily: "'JetBrains Mono', monospace", + fontSize: '0.9rem', + fontWeight: 700, + color: '#0EA5E9', + textTransform: 'uppercase', + letterSpacing: '0.08em', + }, + subtitle: { + fontFamily: "'JetBrains Mono', monospace", + fontSize: '0.72rem', + color: '#94A3B8', + marginTop: '0.25rem', + }, + closeBtn: { + background: 'transparent', + border: 'none', + color: '#64748B', + cursor: 'pointer', + padding: '0.25rem', + borderRadius: '4px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + section: { + marginBottom: '1.25rem', + }, + label: { + fontFamily: "'JetBrains Mono', monospace", + fontSize: '0.68rem', + fontWeight: 600, + color: '#7DD3FC', + textTransform: 'uppercase', + letterSpacing: '0.1em', + marginBottom: '0.4rem', + display: 'block', + }, + input: { + background: 'rgba(15, 23, 42, 0.8)', + border: '1px solid rgba(14, 165, 233, 0.2)', + borderRadius: '8px', + padding: '0.5rem 0.75rem', + color: '#F8FAFC', + fontSize: '0.82rem', + fontFamily: "'JetBrains Mono', monospace", + width: '100%', + outline: 'none', + boxSizing: 'border-box', + transition: 'border-color 0.2s', + }, + inputError: { + borderColor: 'rgba(239, 68, 68, 0.6)', + }, + textarea: { + background: 'rgba(15, 23, 42, 0.8)', + border: '1px solid rgba(14, 165, 233, 0.2)', + borderRadius: '8px', + padding: '0.5rem 0.75rem', + color: '#F8FAFC', + fontSize: '0.78rem', + fontFamily: "'JetBrains Mono', monospace", + width: '100%', + minHeight: '120px', + resize: 'vertical', + outline: 'none', + boxSizing: 'border-box', + transition: 'border-color 0.2s', + }, + readOnlyBadge: { + display: 'inline-block', + fontFamily: "'JetBrains Mono', monospace", + fontSize: '0.7rem', + fontWeight: 600, + color: '#0EA5E9', + background: 'rgba(14, 165, 233, 0.1)', + border: '1px solid rgba(14, 165, 233, 0.3)', + borderRadius: '4px', + padding: '0.25rem 0.6rem', + }, + previewList: { + maxHeight: '160px', + overflowY: 'auto', + border: '1px solid rgba(14, 165, 233, 0.15)', + borderRadius: '8px', + background: 'rgba(15, 23, 42, 0.6)', + }, + previewItem: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0.4rem 0.6rem', + borderBottom: '1px solid rgba(255, 255, 255, 0.04)', + }, + previewItemText: { + flex: 1, + minWidth: 0, + overflow: 'hidden', + }, + previewTitle: { + fontFamily: "'JetBrains Mono', monospace", + fontSize: '0.72rem', + color: '#CBD5E1', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + previewHost: { + fontFamily: "'JetBrains Mono', monospace", + fontSize: '0.62rem', + color: '#64748B', + marginTop: '1px', + }, + removeBtn: { + background: 'transparent', + border: '1px solid rgba(239, 68, 68, 0.3)', + borderRadius: '4px', + color: '#EF4444', + cursor: 'pointer', + padding: '0.15rem 0.3rem', + marginLeft: '0.5rem', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + transition: 'all 0.2s', + }, + errorMsg: { + fontFamily: "'JetBrains Mono', monospace", + fontSize: '0.7rem', + color: '#EF4444', + marginTop: '0.3rem', + }, + warningMsg: { + fontFamily: "'JetBrains Mono', monospace", + fontSize: '0.7rem', + color: '#F59E0B', + background: 'rgba(245, 158, 11, 0.08)', + border: '1px solid rgba(245, 158, 11, 0.2)', + borderRadius: '6px', + padding: '0.5rem 0.75rem', + marginBottom: '1rem', + display: 'flex', + alignItems: 'center', + gap: '0.4rem', + }, + apiError: { + fontFamily: "'JetBrains Mono', monospace", + fontSize: '0.72rem', + color: '#EF4444', + background: 'rgba(239, 68, 68, 0.08)', + border: '1px solid rgba(239, 68, 68, 0.25)', + borderRadius: '6px', + padding: '0.5rem 0.75rem', + marginBottom: '1rem', + display: 'flex', + alignItems: 'center', + gap: '0.4rem', + }, + actions: { + display: 'flex', + gap: '0.75rem', + marginTop: '1.5rem', + }, + cancelBtn: { + flex: 1, + padding: '0.625rem', + background: 'transparent', + border: '1px solid rgba(100, 116, 139, 0.4)', + borderRadius: '0.375rem', + color: '#94A3B8', + cursor: 'pointer', + fontFamily: "'JetBrains Mono', monospace", + fontSize: '0.78rem', + transition: 'all 0.2s ease', + }, + submitBtn: { + flex: 1.5, + padding: '0.625rem', + background: 'rgba(14, 165, 233, 0.1)', + border: '1px solid #0EA5E9', + borderRadius: '0.375rem', + color: '#0EA5E9', + cursor: 'pointer', + fontFamily: "'JetBrains Mono', monospace", + fontSize: '0.78rem', + fontWeight: 600, + textTransform: 'uppercase', + letterSpacing: '0.05em', + transition: 'all 0.2s ease', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '0.4rem', + }, + submitBtnDisabled: { + opacity: 0.4, + cursor: 'not-allowed', + }, + charCount: { + fontFamily: "'JetBrains Mono', monospace", + fontSize: '0.62rem', + color: '#64748B', + textAlign: 'right', + marginTop: '0.2rem', + }, +}; + +/** + * ConsolidationModal — Creates a single Jira ticket from multiple selected + * Ivanti queue items. Pre-populates summary, description, CVE, and vendor + * using aggregation functions. + * + * Props: + * items {Array} — The selected queue items (full objects) + * onClose {Function} — Close handler + * onSuccess {Function} — Called with created ticket data on success + */ +export default function ConsolidationModal({ items, onClose, onSuccess }) { + // Internal state — copy of items that can be modified (items removed) + const [selectedItems, setSelectedItems] = useState(items); + + // Form fields + const [summary, setSummary] = useState(''); + const [description, setDescription] = useState(''); + const [cveId, setCveId] = useState(''); + const [vendor, setVendor] = useState(''); + + // Locked source context + const sourceContext = 'ivanti_queue'; + + // UI state + const [summaryError, setSummaryError] = useState(null); + const [apiError, setApiError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // --------------------------------------------------------------------------- + // Initialize / regenerate form fields when selectedItems changes + // --------------------------------------------------------------------------- + useEffect(() => { + if (selectedItems.length >= 2) { + setSummary(generateConsolidatedSummary(selectedItems)); + setDescription(generateConsolidatedDescription(selectedItems)); + setCveId(extractFirstCve(selectedItems)); + setVendor(extractCommonVendor(selectedItems)); + } + }, []); // Only on mount — user edits are preserved after item removal + + // --------------------------------------------------------------------------- + // Remove an item from the selection (minimum 2 required) + // --------------------------------------------------------------------------- + const removeItem = useCallback((itemId) => { + setSelectedItems((prev) => prev.filter((i) => i.id !== itemId)); + }, []); + + // --------------------------------------------------------------------------- + // Escape key handler + // --------------------------------------------------------------------------- + useEffect(() => { + const handleKey = (e) => { + if (e.key === 'Escape') onClose?.(); + }; + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [onClose]); + + // --------------------------------------------------------------------------- + // Form validation + // --------------------------------------------------------------------------- + const canSubmit = selectedItems.length >= 2 && summary.trim().length > 0 && !submitting; + + // --------------------------------------------------------------------------- + // Submit handler + // --------------------------------------------------------------------------- + const handleSubmit = async () => { + // Validate summary + if (!summary.trim()) { + setSummaryError('Summary is required.'); + return; + } + if (summary.length > 255) { + setSummaryError('Summary must be 255 characters or fewer.'); + return; + } + setSummaryError(null); + setApiError(null); + setSubmitting(true); + + try { + // Step 1: Create the Jira ticket + const createPayload = { + summary: summary.trim(), + description, + cve_id: cveId.trim() || null, + vendor: vendor.trim() || null, + source_context: sourceContext, + }; + + const createRes = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(createPayload), + }); + + const createData = await createRes.json(); + + if (!createRes.ok && createRes.status !== 207) { + throw new Error(createData.error || `Failed to create Jira ticket (HTTP ${createRes.status})`); + } + + const ticketId = createData.id; + + // Step 2: Link queue items to the ticket via junction endpoint + const linkRes = await fetch(`${API_BASE}/jira-tickets/${ticketId}/queue-items`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ queue_item_ids: selectedItems.map((i) => i.id) }), + }); + + if (!linkRes.ok) { + const linkData = await linkRes.json(); + // Ticket was created but linking failed — partial success + console.warn('Junction link failed:', linkData.error || linkRes.status); + } + + // Success — close modal and notify parent + onSuccess?.(createData); + } catch (err) { + setApiError(err.message); + } finally { + setSubmitting(false); + } + }; + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + return ( +
{ if (e.target === e.currentTarget) onClose?.(); }} + > +
+ {/* Header */} +
+
+
+ + Create Consolidated Jira Ticket +
+
+ {selectedItems.length} item{selectedItems.length !== 1 ? 's' : ''} selected for consolidation +
+
+ +
+ + {/* Minimum items warning */} + {selectedItems.length < 2 && ( +
+ + At least 2 items are required for consolidation. Add more items or close this modal. +
+ )} + + {/* API error */} + {apiError && ( +
+ + {apiError} +
+ )} + + {/* Selected items preview list */} +
+ +
+ {selectedItems.map((item) => ( +
+
+
+ {item.finding_title || item.finding_id || 'Untitled'} +
+
+ {item.hostname || 'No hostname'} +
+
+ +
+ ))} +
+
+ + {/* Summary field (required, max 255 chars) */} +
+ + { + setSummary(e.target.value); + if (summaryError) setSummaryError(null); + }} + maxLength={255} + placeholder="Ticket summary (required)" + style={{ ...STYLES.input, ...(summaryError ? STYLES.inputError : {}) }} + /> +
{summary.length}/255
+ {summaryError &&
{summaryError}
} +
+ + {/* Description textarea */} +
+ +