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:
137
backend/__tests__/ivanti-todo-queue-ticket-links.test.js
Normal file
137
backend/__tests__/ivanti-todo-queue-ticket-links.test.js
Normal file
@@ -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.' });
|
||||
});
|
||||
});
|
||||
214
backend/__tests__/jira-ticket-queue-items.test.js
Normal file
214
backend/__tests__/jira-ticket-queue-items.test.js
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
65
backend/migrations/add_multi_item_jira_ticket.js
Normal file
65
backend/migrations/add_multi_item_jira_ticket.js
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user