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)
This commit is contained in:
Jordan Ramos
2026-05-22 11:12:45 -06:00
parent 704432788c
commit 6b805ee633
10 changed files with 2281 additions and 0 deletions

View File

@@ -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
*

View File

@@ -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;
}