Unify Archer and Jira tickets into single tickets table
Add unified tickets table with ticket_type discriminator column ('archer'|'jira').
Migration preserves Jira ticket IDs for FK integrity, updates junction table FK,
and renames old tables to *_legacy for rollback path.
New /api/tickets router provides full CRUD with type-aware validation, plus all
Jira integration endpoints (lookup, sync, create-in-jira). Old routes
(/api/jira-tickets, /api/archer-tickets) refactored as backward-compatible
proxies querying the unified table.
Updated ivantiTodoQueue ticket-links JOIN and server.js CVE cascade queries
to reference the new tickets table.
This commit is contained in:
@@ -116,14 +116,14 @@ describe('GET /api/ivanti/todo-queue/ticket-links', () => {
|
||||
expect(params).toEqual([7]);
|
||||
});
|
||||
|
||||
it('joins jira_ticket_queue_items with jira_tickets and ivanti_todo_queue', async () => {
|
||||
it('joins jira_ticket_queue_items with 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 tickets');
|
||||
expect(sql).toContain('JOIN ivanti_todo_queue');
|
||||
});
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at);
|
||||
|
||||
-- =============================================================================
|
||||
-- Jira integration
|
||||
-- Jira integration (LEGACY — replaced by unified tickets table)
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jira_tickets (
|
||||
@@ -136,7 +136,7 @@ CREATE INDEX IF NOT EXISTS idx_jira_tickets_cve ON jira_tickets(cve_id, vendor);
|
||||
CREATE INDEX IF NOT EXISTS idx_jira_tickets_status ON jira_tickets(status);
|
||||
|
||||
-- =============================================================================
|
||||
-- Archer integration
|
||||
-- Archer integration (LEGACY — replaced by unified tickets table)
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archer_tickets (
|
||||
@@ -155,6 +155,35 @@ CREATE INDEX IF NOT EXISTS idx_archer_tickets_cve ON archer_tickets(cve_id, vend
|
||||
CREATE INDEX IF NOT EXISTS idx_archer_tickets_status ON archer_tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_archer_tickets_exc ON archer_tickets(exc_number);
|
||||
|
||||
-- =============================================================================
|
||||
-- Unified tickets table (replaces jira_tickets and archer_tickets)
|
||||
-- After migration, the above tables are renamed to *_legacy.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticket_type TEXT NOT NULL CHECK (ticket_type IN ('archer', 'jira')),
|
||||
ticket_key TEXT NOT NULL UNIQUE,
|
||||
cve_id TEXT,
|
||||
vendor TEXT,
|
||||
url TEXT,
|
||||
summary TEXT,
|
||||
status TEXT DEFAULT 'Open',
|
||||
jira_id TEXT,
|
||||
jira_status TEXT,
|
||||
last_synced_at TIMESTAMPTZ,
|
||||
source_context TEXT DEFAULT 'manual'
|
||||
CHECK (source_context IN ('cve', 'archer', 'ivanti_queue', 'email', 'manual')),
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_type ON tickets(ticket_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_cve_vendor ON tickets(cve_id, vendor);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_key ON tickets(ticket_key);
|
||||
|
||||
-- =============================================================================
|
||||
-- Knowledge base
|
||||
-- =============================================================================
|
||||
|
||||
@@ -37,6 +37,7 @@ const POSTGRES_MIGRATIONS = [
|
||||
'add_supplemental_tables.js',
|
||||
'add_supplemental_metadata.js',
|
||||
'add_ivanti_findings_scan_type.js',
|
||||
'unify_tickets_table.js',
|
||||
];
|
||||
|
||||
async function runAll() {
|
||||
|
||||
222
backend/migrations/unify_tickets_table.js
Normal file
222
backend/migrations/unify_tickets_table.js
Normal file
@@ -0,0 +1,222 @@
|
||||
// Migration: Unify archer_tickets and jira_tickets into a single tickets table
|
||||
// - Creates the unified `tickets` table with ticket_type discriminator
|
||||
// - Migrates existing data from both source tables
|
||||
// - Updates jira_ticket_queue_items FK to reference tickets(id)
|
||||
// - Renames old tables to *_legacy for rollback path
|
||||
// Idempotent — safe to run multiple times.
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting unified tickets table migration...');
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// 1. Create the unified tickets table
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticket_type TEXT NOT NULL CHECK (ticket_type IN ('archer', 'jira')),
|
||||
ticket_key TEXT NOT NULL UNIQUE,
|
||||
cve_id TEXT,
|
||||
vendor TEXT,
|
||||
url TEXT,
|
||||
summary TEXT,
|
||||
status TEXT DEFAULT 'Open',
|
||||
jira_id TEXT,
|
||||
jira_status TEXT,
|
||||
last_synced_at TIMESTAMPTZ,
|
||||
source_context TEXT DEFAULT 'manual'
|
||||
CHECK (source_context IN ('cve', 'archer', 'ivanti_queue', 'email', 'manual')),
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
console.log('✓ tickets table created (or already exists)');
|
||||
|
||||
// Create indexes
|
||||
await client.query(`CREATE INDEX IF NOT EXISTS idx_tickets_type ON tickets(ticket_type)`);
|
||||
await client.query(`CREATE INDEX IF NOT EXISTS idx_tickets_cve_vendor ON tickets(cve_id, vendor)`);
|
||||
await client.query(`CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status)`);
|
||||
await client.query(`CREATE INDEX IF NOT EXISTS idx_tickets_key ON tickets(ticket_key)`);
|
||||
console.log('✓ indexes created (or already exist)');
|
||||
|
||||
// 2. Check if source tables still exist (not yet renamed)
|
||||
const { rows: jiraTableExists } = await client.query(`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'jira_tickets'
|
||||
`);
|
||||
|
||||
const { rows: archerTableExists } = await client.query(`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'archer_tickets'
|
||||
`);
|
||||
|
||||
// 3. Migrate Jira tickets (preserve original IDs)
|
||||
if (jiraTableExists.length > 0) {
|
||||
// Check if jira_tickets has the extended columns (from flexible-jira-ticket-creation migration)
|
||||
const { rows: jiraColCheck } = await client.query(`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'jira_tickets' AND column_name = 'source_context'
|
||||
`);
|
||||
const hasSourceContext = jiraColCheck.length > 0;
|
||||
|
||||
const { rows: jiraIdCheck } = await client.query(`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'jira_tickets' AND column_name = 'jira_id'
|
||||
`);
|
||||
const hasJiraId = jiraIdCheck.length > 0;
|
||||
|
||||
const { rows: createdByCheck } = await client.query(`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'jira_tickets' AND column_name = 'created_by'
|
||||
`);
|
||||
const hasCreatedBy = createdByCheck.length > 0;
|
||||
|
||||
// Build dynamic SELECT for jira_tickets based on available columns
|
||||
const jiraSelect = `
|
||||
SELECT
|
||||
id,
|
||||
'jira' AS ticket_type,
|
||||
ticket_key,
|
||||
cve_id,
|
||||
vendor,
|
||||
url,
|
||||
summary,
|
||||
status,
|
||||
${hasJiraId ? 'jira_id' : 'NULL AS jira_id'},
|
||||
${hasJiraId ? 'jira_status' : 'NULL AS jira_status'},
|
||||
${hasJiraId ? 'last_synced_at' : 'NULL::timestamptz AS last_synced_at'},
|
||||
${hasSourceContext ? 'source_context' : "'manual' AS source_context"},
|
||||
${hasCreatedBy ? 'created_by' : 'NULL::integer AS created_by'},
|
||||
created_at,
|
||||
updated_at
|
||||
FROM jira_tickets
|
||||
`;
|
||||
|
||||
const { rowCount: jiraCount } = await client.query(`
|
||||
INSERT INTO tickets (id, ticket_type, ticket_key, cve_id, vendor, url, summary, status, jira_id, jira_status, last_synced_at, source_context, created_by, created_at, updated_at)
|
||||
${jiraSelect}
|
||||
ON CONFLICT (ticket_key) DO NOTHING
|
||||
`);
|
||||
console.log(`✓ Migrated ${jiraCount} Jira ticket(s) into unified table`);
|
||||
} else {
|
||||
console.log('⊘ jira_tickets table not found (already renamed or does not exist)');
|
||||
}
|
||||
|
||||
// 4. Migrate Archer tickets (auto-increment IDs — no FK references archer_tickets.id)
|
||||
if (archerTableExists.length > 0) {
|
||||
const { rowCount: archerCount } = await client.query(`
|
||||
INSERT INTO tickets (ticket_type, ticket_key, cve_id, vendor, url, summary, status, source_context, created_by, created_at, updated_at)
|
||||
SELECT
|
||||
'archer',
|
||||
exc_number,
|
||||
cve_id,
|
||||
vendor,
|
||||
archer_url,
|
||||
NULL,
|
||||
status,
|
||||
'manual',
|
||||
created_by,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM archer_tickets
|
||||
ON CONFLICT (ticket_key) DO NOTHING
|
||||
`);
|
||||
console.log(`✓ Migrated ${archerCount} Archer ticket(s) into unified table`);
|
||||
} else {
|
||||
console.log('⊘ archer_tickets table not found (already renamed or does not exist)');
|
||||
}
|
||||
|
||||
// 5. Reset sequence to max(id) + 1
|
||||
const { rows: maxIdRow } = await client.query(`SELECT COALESCE(MAX(id), 0) AS max_id FROM tickets`);
|
||||
const nextVal = maxIdRow[0].max_id + 1;
|
||||
await client.query(`SELECT setval('tickets_id_seq', $1, false)`, [nextVal]);
|
||||
console.log(`✓ Sequence reset to ${nextVal}`);
|
||||
|
||||
// 6. Update jira_ticket_queue_items FK to reference tickets(id)
|
||||
const { rows: junctionExists } = await client.query(`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'jira_ticket_queue_items'
|
||||
`);
|
||||
|
||||
if (junctionExists.length > 0) {
|
||||
// Check if the FK already points to tickets table
|
||||
const { rows: fkCheck } = await client.query(`
|
||||
SELECT tc.constraint_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
|
||||
WHERE tc.table_name = 'jira_ticket_queue_items'
|
||||
AND tc.constraint_type = 'FOREIGN KEY'
|
||||
AND ccu.table_name = 'jira_tickets'
|
||||
AND ccu.column_name = 'id'
|
||||
`);
|
||||
|
||||
if (fkCheck.length > 0) {
|
||||
// Drop old FK referencing jira_tickets
|
||||
for (const row of fkCheck) {
|
||||
await client.query(`ALTER TABLE jira_ticket_queue_items DROP CONSTRAINT IF EXISTS ${row.constraint_name}`);
|
||||
}
|
||||
// Add new FK referencing tickets
|
||||
await client.query(`
|
||||
ALTER TABLE jira_ticket_queue_items
|
||||
ADD CONSTRAINT jira_ticket_queue_items_ticket_id_fkey
|
||||
FOREIGN KEY (jira_ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
|
||||
`);
|
||||
console.log('✓ jira_ticket_queue_items FK updated to reference tickets(id)');
|
||||
} else {
|
||||
// Check if it already references tickets
|
||||
const { rows: newFkCheck } = await client.query(`
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
|
||||
WHERE tc.table_name = 'jira_ticket_queue_items'
|
||||
AND tc.constraint_type = 'FOREIGN KEY'
|
||||
AND ccu.table_name = 'tickets'
|
||||
`);
|
||||
if (newFkCheck.length > 0) {
|
||||
console.log('✓ jira_ticket_queue_items FK already references tickets(id)');
|
||||
} else {
|
||||
// No FK exists — add one
|
||||
await client.query(`
|
||||
ALTER TABLE jira_ticket_queue_items
|
||||
ADD CONSTRAINT jira_ticket_queue_items_ticket_id_fkey
|
||||
FOREIGN KEY (jira_ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
|
||||
`);
|
||||
console.log('✓ jira_ticket_queue_items FK added referencing tickets(id)');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('⊘ jira_ticket_queue_items table not found — skipping FK update');
|
||||
}
|
||||
|
||||
// 7. Rename old tables to *_legacy
|
||||
if (jiraTableExists.length > 0) {
|
||||
await client.query(`ALTER TABLE IF EXISTS jira_tickets RENAME TO jira_tickets_legacy`);
|
||||
console.log('✓ jira_tickets renamed to jira_tickets_legacy');
|
||||
}
|
||||
|
||||
if (archerTableExists.length > 0) {
|
||||
await client.query(`ALTER TABLE IF EXISTS archer_tickets RENAME TO archer_tickets_legacy`);
|
||||
console.log('✓ archer_tickets renamed to archer_tickets_legacy');
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
console.log('Migration complete.');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Migration failed, rolled back:', err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Migration error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
// routes/archerTickets.js
|
||||
// Backward-compatible proxy — queries the unified `tickets` table filtered by ticket_type = 'archer'.
|
||||
// Maps ticket_key ↔ exc_number and url ↔ archer_url in request/response for API compatibility.
|
||||
// This route is deprecated — new consumers should use /api/tickets?type=archer instead.
|
||||
const express = require('express');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
@@ -14,6 +17,21 @@ function isValidVendor(vendor) {
|
||||
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||
}
|
||||
|
||||
// Map unified row to legacy response shape
|
||||
function toArcherShape(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
exc_number: row.ticket_key,
|
||||
archer_url: row.url,
|
||||
status: row.status,
|
||||
cve_id: row.cve_id,
|
||||
vendor: row.vendor,
|
||||
created_by: row.created_by,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function createArcherTicketsRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
@@ -21,7 +39,7 @@ function createArcherTicketsRouter() {
|
||||
router.get('/', requireAuth(), async (req, res) => {
|
||||
const { cve_id, vendor, status } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM archer_tickets WHERE 1=1';
|
||||
let query = "SELECT * FROM tickets WHERE ticket_type = 'archer'";
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
@@ -42,7 +60,7 @@ function createArcherTicketsRouter() {
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(query, params);
|
||||
res.json(rows);
|
||||
res.json(rows.map(toArcherShape));
|
||||
} catch (err) {
|
||||
console.error('Error fetching Archer tickets:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
@@ -77,8 +95,8 @@ function createArcherTicketsRouter() {
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`INSERT INTO tickets (ticket_type, ticket_key, url, status, cve_id, vendor, source_context, created_by)
|
||||
VALUES ('archer', $1, $2, $3, $4, $5, 'manual', $6)
|
||||
RETURNING id`,
|
||||
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id]
|
||||
);
|
||||
@@ -86,7 +104,7 @@ function createArcherTicketsRouter() {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
action: 'CREATE_ARCHER_TICKET',
|
||||
entityType: 'archer_ticket',
|
||||
entityType: 'ticket',
|
||||
entityId: String(rows[0].id),
|
||||
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
|
||||
ipAddress: req.ip
|
||||
@@ -127,7 +145,7 @@ function createArcherTicketsRouter() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM archer_tickets WHERE id = $1', [id]);
|
||||
const { rows } = await pool.query("SELECT * FROM tickets WHERE id = $1 AND ticket_type = 'archer'", [id]);
|
||||
const existing = rows[0];
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Archer ticket not found.' });
|
||||
@@ -138,11 +156,11 @@ function createArcherTicketsRouter() {
|
||||
let paramIndex = 1;
|
||||
|
||||
if (exc_number !== undefined) {
|
||||
updates.push(`exc_number = $${paramIndex++}`);
|
||||
updates.push(`ticket_key = $${paramIndex++}`);
|
||||
params.push(exc_number.trim());
|
||||
}
|
||||
if (archer_url !== undefined) {
|
||||
updates.push(`archer_url = $${paramIndex++}`);
|
||||
updates.push(`url = $${paramIndex++}`);
|
||||
params.push(archer_url || null);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
@@ -158,16 +176,16 @@ function createArcherTicketsRouter() {
|
||||
params.push(id);
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
|
||||
`UPDATE tickets SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
|
||||
params
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
action: 'UPDATE_ARCHER_TICKET',
|
||||
entityType: 'archer_ticket',
|
||||
entityType: 'ticket',
|
||||
entityId: String(id),
|
||||
details: { before: existing, changes: req.body },
|
||||
details: { before: toArcherShape(existing), changes: req.body },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
@@ -186,7 +204,7 @@ function createArcherTicketsRouter() {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM archer_tickets WHERE id = $1', [id]);
|
||||
const { rows } = await pool.query("SELECT * FROM tickets WHERE id = $1 AND ticket_type = 'archer'", [id]);
|
||||
const ticket = rows[0];
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'Archer ticket not found.' });
|
||||
@@ -203,7 +221,7 @@ function createArcherTicketsRouter() {
|
||||
}
|
||||
|
||||
// Standard_User: compliance linkage check
|
||||
const excNumber = ticket.exc_number;
|
||||
const excNumber = ticket.ticket_key;
|
||||
try {
|
||||
const { rows: compLinks } = await pool.query(
|
||||
`SELECT ci.id, ci.extra_json
|
||||
@@ -228,14 +246,14 @@ function createArcherTicketsRouter() {
|
||||
return performArcherDelete();
|
||||
|
||||
async function performArcherDelete() {
|
||||
await pool.query('DELETE FROM archer_tickets WHERE id = $1', [id]);
|
||||
await pool.query('DELETE FROM tickets WHERE id = $1', [id]);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
action: 'DELETE_ARCHER_TICKET',
|
||||
entityType: 'archer_ticket',
|
||||
entityType: 'ticket',
|
||||
entityId: String(id),
|
||||
details: { deleted: ticket },
|
||||
details: { deleted: toArcherShape(ticket) },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
@@ -252,7 +270,8 @@ function createArcherTicketsRouter() {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DATE(created_at) AS date, status, COUNT(*) AS count
|
||||
FROM archer_tickets
|
||||
FROM tickets
|
||||
WHERE ticket_type = 'archer'
|
||||
GROUP BY DATE(created_at), status
|
||||
ORDER BY date ASC`
|
||||
);
|
||||
|
||||
@@ -458,7 +458,7 @@ 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.
|
||||
* Joins jira_ticket_queue_items with tickets to get ticket_key and url.
|
||||
*
|
||||
* @returns {Object} { links: { [queue_item_id]: { ticket_key, jira_url } } }
|
||||
* @error 500 Internal server error
|
||||
@@ -468,7 +468,7 @@ function createIvantiTodoQueueRouter() {
|
||||
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 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]
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
// routes/jiraTickets.js
|
||||
// Jira ticket CRUD + Jira REST API integration endpoints.
|
||||
// Extracted from server.js inline endpoints and extended with live Jira
|
||||
// operations (lookup, sync, create-in-jira, connection test).
|
||||
//
|
||||
// Charter Jira REST API compliance:
|
||||
// - All GETs include explicit field lists (no /rest/api/2/field)
|
||||
// - Sync uses bulk JQL search, not one-issue-at-a-time GETs
|
||||
// - No /rest/api/2/issue/bulk — updates are one at a time
|
||||
// - Inter-request delays enforced in jiraApi.js (1s GET, 2s write)
|
||||
// - Rate limits enforced client-side (1440/day, 60/min burst)
|
||||
// Backward-compatible proxy — queries the unified `tickets` table filtered by ticket_type = 'jira'.
|
||||
// This route is deprecated — new consumers should use /api/tickets?type=jira instead.
|
||||
// All Jira integration endpoints are also available at /api/tickets/jira/*.
|
||||
|
||||
const express = require('express');
|
||||
const pool = require('../db');
|
||||
@@ -129,7 +122,6 @@ function createJiraTicketsRouter() {
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
// Build a meaningful error message from Jira's response
|
||||
let errorMsg = result.status === 404 ? 'Issue not found in Jira.' : 'Jira API error.';
|
||||
if (result.body) {
|
||||
try {
|
||||
@@ -157,7 +149,7 @@ function createJiraTicketsRouter() {
|
||||
/**
|
||||
* POST /api/jira-tickets/create-in-jira
|
||||
*
|
||||
* Creates a new issue in Jira and saves a local tracking record.
|
||||
* Creates a new issue in Jira and saves a local tracking record in the unified tickets table.
|
||||
*
|
||||
* @requires Admin or Standard_User group
|
||||
* @body {string} [cve_id] - Optional CVE ID (format: CVE-YYYY-NNNN+); stored as NULL if absent/empty
|
||||
@@ -181,7 +173,6 @@ function createJiraTicketsRouter() {
|
||||
|
||||
const { cve_id, vendor, summary, description, project_key, issue_type, source_context } = req.body;
|
||||
|
||||
// --- CVE ID validation: optional, but must match format if non-empty ---
|
||||
let normalizedCveId = null;
|
||||
if (cve_id !== undefined && cve_id !== null && cve_id !== '') {
|
||||
if (!isValidCveId(cve_id)) {
|
||||
@@ -190,7 +181,6 @@ function createJiraTicketsRouter() {
|
||||
normalizedCveId = cve_id;
|
||||
}
|
||||
|
||||
// --- Vendor validation: optional, but must be <= 200 chars after trim if non-empty ---
|
||||
let normalizedVendor = null;
|
||||
if (vendor !== undefined && vendor !== null && typeof vendor === 'string' && vendor.trim().length > 0) {
|
||||
const trimmedVendor = vendor.trim();
|
||||
@@ -200,7 +190,6 @@ function createJiraTicketsRouter() {
|
||||
normalizedVendor = trimmedVendor;
|
||||
}
|
||||
|
||||
// --- source_context validation: must be in allowed set if provided, default to 'manual' ---
|
||||
const ALLOWED_SOURCE_CONTEXTS = ['cve', 'archer', 'ivanti_queue', 'email', 'manual'];
|
||||
let normalizedSourceContext = 'manual';
|
||||
if (source_context !== undefined && source_context !== null) {
|
||||
@@ -210,7 +199,6 @@ function createJiraTicketsRouter() {
|
||||
normalizedSourceContext = source_context;
|
||||
}
|
||||
|
||||
// --- Summary validation: required, non-empty, max 255 chars ---
|
||||
if (!summary || typeof summary !== 'string' || summary.trim().length === 0 || summary.length > 255) {
|
||||
return res.status(400).json({ error: 'Summary is required (max 255 chars).' });
|
||||
}
|
||||
@@ -227,7 +215,6 @@ function createJiraTicketsRouter() {
|
||||
summary: summary.trim(),
|
||||
issuetype: { name: issueType }
|
||||
};
|
||||
|
||||
if (description) {
|
||||
fields.description = description;
|
||||
}
|
||||
@@ -249,19 +236,19 @@ function createJiraTicketsRouter() {
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by, source_context)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, $10)
|
||||
`INSERT INTO tickets (ticket_type, cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by, source_context)
|
||||
VALUES ('jira', $1, $2, $3, $4, $5, 'Open', $6, 'Open', NOW(), $7, $8)
|
||||
RETURNING id`,
|
||||
[normalizedCveId, normalizedVendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id, normalizedSourceContext]
|
||||
[normalizedCveId, normalizedVendor, ticketKey, jiraUrl, summary.trim(), jiraIssue.id, req.user.id, normalizedSourceContext]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_create_via_api',
|
||||
entityType: 'jira_ticket',
|
||||
entityType: 'ticket',
|
||||
entityId: rows[0].id.toString(),
|
||||
details: { cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey, source_context: normalizedSourceContext },
|
||||
details: { ticket_type: 'jira', cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey, source_context: normalizedSourceContext },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
@@ -294,7 +281,7 @@ function createJiraTicketsRouter() {
|
||||
* Stops early if rate limits are approaching.
|
||||
*
|
||||
* @requires Admin group
|
||||
* @returns {object} 200 - { synced, failed, skipped, unchanged, errors: string[] }
|
||||
* @returns {object} 200 - { synced, failed, skipped, unchanged, errors: string[], skippedCompleted }
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
||||
*/
|
||||
@@ -304,14 +291,10 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Only sync tickets that are NOT in a completed/closed state.
|
||||
// Completed tickets are pulled on the sync where they first become completed,
|
||||
// but on subsequent syncs they are skipped to avoid unnecessary API calls.
|
||||
const { rows: tickets } = await pool.query(
|
||||
"SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''"
|
||||
"SELECT * FROM tickets WHERE ticket_type = 'jira' AND ticket_key IS NOT NULL AND ticket_key != ''"
|
||||
);
|
||||
|
||||
// Separate active vs completed tickets
|
||||
const CLOSED_STATUSES = ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'];
|
||||
const isCompleted = (status) => {
|
||||
if (!status) return false;
|
||||
@@ -371,11 +354,12 @@ function createJiraTicketsRouter() {
|
||||
|
||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE jira_tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
|
||||
[jiraSummary, jiraStatus || 'Open', jiraStatus, ticket.id]
|
||||
`UPDATE tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
|
||||
[jiraSummary, localStatus, jiraStatus, ticket.id]
|
||||
);
|
||||
results.synced++;
|
||||
} catch (dbErr) {
|
||||
@@ -430,7 +414,7 @@ function createJiraTicketsRouter() {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
|
||||
const { rows } = await pool.query("SELECT * FROM tickets WHERE id = $1 AND ticket_type = 'jira'", [id]);
|
||||
const ticket = rows[0];
|
||||
|
||||
if (!ticket) {
|
||||
@@ -451,17 +435,18 @@ function createJiraTicketsRouter() {
|
||||
const issue = result.data;
|
||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||
|
||||
await pool.query(
|
||||
`UPDATE jira_tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
|
||||
[jiraSummary, jiraStatus || 'Open', jiraStatus, id]
|
||||
`UPDATE tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
|
||||
[jiraSummary, localStatus, jiraStatus, id]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_sync',
|
||||
entityType: 'jira_ticket',
|
||||
entityType: 'ticket',
|
||||
entityId: id,
|
||||
details: { ticket_key: ticket.ticket_key, jira_status: jiraStatus, local_status: localStatus },
|
||||
ipAddress: req.ip
|
||||
@@ -481,7 +466,7 @@ function createJiraTicketsRouter() {
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Local CRUD endpoints
|
||||
// Local CRUD endpoints (backward-compatible)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -492,16 +477,16 @@ function createJiraTicketsRouter() {
|
||||
*
|
||||
* @query {string} [cve_id] - Filter by exact CVE ID
|
||||
* @query {string} [vendor] - Filter by exact vendor name
|
||||
* @query {string} [status] - Filter by ticket status (Open, In Progress, Closed)
|
||||
* @query {string} [status] - Filter by ticket status
|
||||
* @query {string} [source_context] - Filter by source context (cve, archer, ivanti_queue, email, manual)
|
||||
* @requires Authenticated user
|
||||
* @returns {array} 200 - Array of jira_tickets rows
|
||||
* @returns {array} 200 - Array of ticket rows (ticket_type = 'jira')
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
*/
|
||||
router.get('/', requireAuth(), async (req, res) => {
|
||||
const { cve_id, vendor, status, source_context } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
||||
let query = "SELECT * FROM tickets WHERE ticket_type = 'jira'";
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
@@ -540,20 +525,20 @@ function createJiraTicketsRouter() {
|
||||
* Used for manually tracking tickets that already exist in Jira.
|
||||
*
|
||||
* @requires Admin or Standard_User group
|
||||
* @body {string} cve_id - Required CVE ID (format: CVE-YYYY-NNNN+)
|
||||
* @body {string} vendor - Required vendor name (max 200 chars)
|
||||
* @body {string} [cve_id] - Optional CVE ID (format: CVE-YYYY-NNNN+)
|
||||
* @body {string} [vendor] - Optional vendor name (max 200 chars)
|
||||
* @body {string} ticket_key - Required Jira ticket key (max 50 chars)
|
||||
* @body {string} [url] - Optional Jira ticket URL (max 500 chars)
|
||||
* @body {string} [summary] - Optional summary (max 500 chars)
|
||||
* @body {string} [status] - Optional status: Open, In Progress, or Closed (defaults to Open)
|
||||
* @body {string} [status] - Optional status (defaults to 'Open')
|
||||
* @returns {object} 201 - { id, message }
|
||||
* @returns {object} 400 - { error: string } for validation failures
|
||||
* @returns {object} 409 - { error: string } when ticket_key already exists
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
*/
|
||||
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||
|
||||
// CVE ID is optional — validate format only if provided and non-empty
|
||||
let normalizedCveId = null;
|
||||
if (cve_id && typeof cve_id === 'string' && cve_id.trim().length > 0) {
|
||||
if (!isValidCveId(cve_id)) {
|
||||
@@ -561,7 +546,6 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
normalizedCveId = cve_id;
|
||||
}
|
||||
// Vendor is optional — validate length only if provided and non-empty
|
||||
let normalizedVendor = null;
|
||||
if (vendor && typeof vendor === 'string' && vendor.trim().length > 0) {
|
||||
if (vendor.trim().length > 200) {
|
||||
@@ -586,8 +570,8 @@ function createJiraTicketsRouter() {
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`INSERT INTO tickets (ticket_type, cve_id, vendor, ticket_key, url, summary, status, source_context, created_by)
|
||||
VALUES ('jira', $1, $2, $3, $4, $5, $6, 'manual', $7)
|
||||
RETURNING id`,
|
||||
[normalizedCveId, normalizedVendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id]
|
||||
);
|
||||
@@ -596,7 +580,7 @@ function createJiraTicketsRouter() {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_create',
|
||||
entityType: 'jira_ticket',
|
||||
entityType: 'ticket',
|
||||
entityId: rows[0].id.toString(),
|
||||
details: { cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key, status: ticketStatus },
|
||||
ipAddress: req.ip
|
||||
@@ -608,6 +592,9 @@ function createJiraTicketsRouter() {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error creating JIRA ticket:', err);
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({ error: 'A ticket with this key already exists.' });
|
||||
}
|
||||
res.status(500).json({ error: `Failed to save ticket: ${err.message}` });
|
||||
}
|
||||
});
|
||||
@@ -625,28 +612,26 @@ function createJiraTicketsRouter() {
|
||||
* @body {string} [ticket_key] - Jira ticket key (max 50 chars)
|
||||
* @body {string} [url] - Jira ticket URL (max 500 chars, null to clear)
|
||||
* @body {string} [summary] - Summary (max 500 chars, null to clear)
|
||||
* @body {string} [status] - Status: Open, In Progress, or Closed
|
||||
* @body {string} [status] - Ticket status
|
||||
* @returns {object} 200 - { message, changes }
|
||||
* @returns {object} 400 - { error: string } for validation failures or source_context mutation attempt
|
||||
* @returns {object} 404 - { error: string } when ticket not found
|
||||
* @returns {object} 409 - { error: string } when ticket_key already exists
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
*/
|
||||
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||
|
||||
// source_context is immutable after creation (Requirement 3.6)
|
||||
if ('source_context' in req.body) {
|
||||
return res.status(400).json({ error: 'source_context is immutable after creation' });
|
||||
}
|
||||
|
||||
// Validate cve_id if provided
|
||||
if (cve_id !== undefined && cve_id !== null && cve_id !== '') {
|
||||
if (!isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
|
||||
}
|
||||
}
|
||||
// Validate vendor if provided
|
||||
if (vendor !== undefined && vendor !== null && typeof vendor === 'string' && vendor.trim().length > 200) {
|
||||
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
|
||||
}
|
||||
@@ -682,14 +667,14 @@ function createJiraTicketsRouter() {
|
||||
values.push(id);
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
|
||||
const { rows } = await pool.query("SELECT * FROM tickets WHERE id = $1 AND ticket_type = 'jira'", [id]);
|
||||
const existing = rows[0];
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = $${paramIndex}`,
|
||||
`UPDATE tickets SET ${fields.join(', ')} WHERE id = $${paramIndex}`,
|
||||
values
|
||||
);
|
||||
|
||||
@@ -697,7 +682,7 @@ function createJiraTicketsRouter() {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_update',
|
||||
entityType: 'jira_ticket',
|
||||
entityType: 'ticket',
|
||||
entityId: id,
|
||||
details: { before: existing, changes: req.body },
|
||||
ipAddress: req.ip
|
||||
@@ -706,6 +691,9 @@ function createJiraTicketsRouter() {
|
||||
res.json({ message: 'JIRA ticket updated successfully', changes: result.rowCount });
|
||||
} catch (err) {
|
||||
console.error('Error updating JIRA ticket:', err);
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({ error: 'A ticket with this key already exists.' });
|
||||
}
|
||||
res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
@@ -713,9 +701,9 @@ function createJiraTicketsRouter() {
|
||||
/**
|
||||
* DELETE /api/jira-tickets/:id
|
||||
*
|
||||
* Deletes a local Jira ticket record. Admin can delete any ticket.
|
||||
* Standard_User can only delete tickets they created, and only if the ticket
|
||||
* is not linked to an active compliance item.
|
||||
* Deletes a local Jira ticket record and its associated queue junction rows.
|
||||
* Admin can delete any ticket. Standard_User can only delete tickets they created,
|
||||
* and only if the ticket is not linked to an active compliance item.
|
||||
*
|
||||
* @param {string} id - Local ticket ID (path parameter)
|
||||
* @requires Admin or Standard_User group
|
||||
@@ -728,7 +716,7 @@ function createJiraTicketsRouter() {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
|
||||
const { rows } = await pool.query("SELECT * FROM tickets WHERE id = $1 AND ticket_type = 'jira'", [id]);
|
||||
const ticket = rows[0];
|
||||
|
||||
if (!ticket) {
|
||||
@@ -771,13 +759,15 @@ function createJiraTicketsRouter() {
|
||||
return performJiraDelete();
|
||||
|
||||
async function performJiraDelete() {
|
||||
await pool.query('DELETE FROM jira_tickets WHERE id = $1', [id]);
|
||||
// Cascade delete junction rows
|
||||
await pool.query('DELETE FROM jira_ticket_queue_items WHERE jira_ticket_id = $1', [id]);
|
||||
await pool.query('DELETE FROM tickets WHERE id = $1', [id]);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_delete',
|
||||
entityType: 'jira_ticket',
|
||||
entityType: 'ticket',
|
||||
entityId: id,
|
||||
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
|
||||
ipAddress: req.ip
|
||||
@@ -791,10 +781,6 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Junction table endpoint — link queue items to a Jira ticket
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* POST /api/jira-tickets/:id/queue-items
|
||||
*
|
||||
@@ -813,7 +799,6 @@ function createJiraTicketsRouter() {
|
||||
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' });
|
||||
}
|
||||
@@ -825,16 +810,14 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the jira_ticket exists
|
||||
const { rows: ticketRows } = await pool.query(
|
||||
'SELECT id FROM jira_tickets WHERE id = $1',
|
||||
"SELECT id FROM tickets WHERE id = $1 AND ticket_type = 'jira'",
|
||||
[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]
|
||||
@@ -843,8 +826,7 @@ function createJiraTicketsRouter() {
|
||||
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 values = queue_item_ids.map((_qid, idx) => `($1, $${idx + 2})`).join(', ');
|
||||
const params = [id, ...queue_item_ids];
|
||||
|
||||
const { rowCount } = await pool.query(
|
||||
@@ -858,7 +840,7 @@ function createJiraTicketsRouter() {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_link_queue_items',
|
||||
entityType: 'jira_ticket',
|
||||
entityType: 'ticket',
|
||||
entityId: id,
|
||||
details: { queue_item_ids, linked_count: rowCount },
|
||||
ipAddress: req.ip
|
||||
|
||||
967
backend/routes/tickets.js
Normal file
967
backend/routes/tickets.js
Normal file
@@ -0,0 +1,967 @@
|
||||
// routes/tickets.js
|
||||
// Unified ticket CRUD + Jira REST API integration endpoints.
|
||||
// Replaces separate jiraTickets.js and archerTickets.js with a single router
|
||||
// operating on the unified `tickets` table with a `ticket_type` discriminator.
|
||||
|
||||
const express = require('express');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
const jiraApi = require('../helpers/jiraApi');
|
||||
|
||||
// Validation helpers
|
||||
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||
const ARCHER_KEY_PATTERN = /^EXC-\d+$/;
|
||||
const JIRA_KEY_PATTERN = /^[A-Z][A-Z0-9_]+-\d+$/;
|
||||
const ALLOWED_SOURCE_CONTEXTS = ['cve', 'archer', 'ivanti_queue', 'email', 'manual'];
|
||||
const ARCHER_STATUSES = ['Draft', 'Open', 'Under Review', 'Accepted'];
|
||||
|
||||
function isValidCveId(cveId) {
|
||||
return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId);
|
||||
}
|
||||
|
||||
function isValidVendor(vendor) {
|
||||
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||
}
|
||||
|
||||
function mapJiraStatusToLocal(jiraStatus) {
|
||||
if (!jiraStatus) return 'Open';
|
||||
const lower = jiraStatus.toLowerCase();
|
||||
if (['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'].some(s => lower.includes(s))) {
|
||||
return 'Closed';
|
||||
}
|
||||
if (['in progress', 'in review', 'in development', 'in testing', 'review', 'testing', 'dev', 'active', 'implementing'].some(s => lower.includes(s))) {
|
||||
return 'In Progress';
|
||||
}
|
||||
return 'Open';
|
||||
}
|
||||
|
||||
function createTicketsRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// All tickets routes require authentication
|
||||
router.use(requireAuth());
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Archer-specific endpoints (must come before /:id to avoid param capture)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/tickets/archer/status-trend
|
||||
*
|
||||
* Returns Archer ticket counts grouped by creation date and status.
|
||||
*
|
||||
* @response 200 - { statusTrend: [{ date: string, status: string, count: number }] }
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.get('/archer/status-trend', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DATE(created_at) AS date, status, COUNT(*) AS count
|
||||
FROM tickets
|
||||
WHERE ticket_type = 'archer'
|
||||
GROUP BY DATE(created_at), status
|
||||
ORDER BY date ASC`
|
||||
);
|
||||
res.json({ statusTrend: rows });
|
||||
} catch (err) {
|
||||
console.error('Error fetching Archer status trend:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Jira integration endpoints (must come before /:id to avoid param capture)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/tickets/jira/connection-test
|
||||
*
|
||||
* Tests connectivity to the configured Jira instance. Admin only.
|
||||
*
|
||||
* @response 200 - { connected: true, user: object }
|
||||
* @response 502 - { connected: false, status: number, error: string }
|
||||
* @response 503 - { error: string } — Jira not configured
|
||||
*/
|
||||
router.get('/jira/connection-test', requireGroup('Admin'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured. Set JIRA_BASE_URL and credentials in backend/.env.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jiraApi.testConnection();
|
||||
if (result.ok) {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_connection_test',
|
||||
entityType: 'jira_integration',
|
||||
entityId: null,
|
||||
details: { success: true, user: result.user.name },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
return res.json({ connected: true, user: result.user });
|
||||
}
|
||||
return res.status(502).json({ connected: false, status: result.status, error: result.body || result.error });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ connected: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tickets/jira/rate-limit
|
||||
*
|
||||
* Returns the current Jira API rate limit status. Admin only.
|
||||
*
|
||||
* @response 200 - { burst: { remaining, limit, resetAt }, daily: { remaining, limit, resetAt } }
|
||||
*/
|
||||
router.get('/jira/rate-limit', requireGroup('Admin'), (req, res) => {
|
||||
res.json(jiraApi.getRateLimitStatus());
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tickets/jira/lookup/:issueKey
|
||||
*
|
||||
* Looks up a single Jira issue by its key via the Jira REST API.
|
||||
*
|
||||
* @param {string} issueKey - Jira issue key (e.g., VULN-123)
|
||||
* @response 200 - { key, summary, status, assignee, priority, issuetype, created, updated, self }
|
||||
* @response 400 - { error: string } — invalid key format
|
||||
* @response 404 - { error: string } — issue not found in Jira
|
||||
* @response 429 - { error: string } — rate limit exceeded
|
||||
* @response 502 - { error: string, details: object } — Jira API error
|
||||
* @response 503 - { error: string } — Jira not configured
|
||||
*/
|
||||
router.get('/jira/lookup/:issueKey', async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
const { issueKey } = req.params;
|
||||
if (!issueKey || !JIRA_KEY_PATTERN.test(issueKey)) {
|
||||
return res.status(400).json({ error: 'Invalid Jira issue key format. Expected PROJECT-123.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jiraApi.getIssue(issueKey);
|
||||
if (result.ok) {
|
||||
const issue = result.data;
|
||||
return res.json({
|
||||
key: issue.key,
|
||||
summary: issue.fields.summary,
|
||||
status: issue.fields.status ? issue.fields.status.name : null,
|
||||
assignee: issue.fields.assignee ? issue.fields.assignee.displayName : null,
|
||||
priority: issue.fields.priority ? issue.fields.priority.name : null,
|
||||
issuetype: issue.fields.issuetype ? issue.fields.issuetype.name : null,
|
||||
created: issue.fields.created,
|
||||
updated: issue.fields.updated,
|
||||
self: issue.self
|
||||
});
|
||||
}
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
let errorMsg = result.status === 404 ? 'Issue not found in Jira.' : 'Jira API error.';
|
||||
if (result.body) {
|
||||
try {
|
||||
const parsed = typeof result.body === 'string' ? JSON.parse(result.body) : result.body;
|
||||
if (parsed.errorMessages && parsed.errorMessages.length > 0) {
|
||||
errorMsg = parsed.errorMessages.join('; ');
|
||||
} else if (parsed.errors && Object.keys(parsed.errors).length > 0) {
|
||||
errorMsg = Object.values(parsed.errors).join('; ');
|
||||
}
|
||||
} catch (_) {
|
||||
if (typeof result.body === 'string' && result.body.length < 300) {
|
||||
errorMsg = result.body;
|
||||
}
|
||||
}
|
||||
}
|
||||
return res.status(result.status === 404 ? 404 : 502).json({ error: errorMsg, details: result.body });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tickets/jira/create-in-jira
|
||||
*
|
||||
* Creates a new issue in Jira and saves a local tracking record.
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @body {string} summary - Issue summary (required, max 255 chars)
|
||||
* @body {string} [cve_id] - CVE ID to associate (CVE-YYYY-NNNNN format)
|
||||
* @body {string} [vendor] - Vendor name (max 200 chars)
|
||||
* @body {string} [description] - Issue description body
|
||||
* @body {string} [project_key] - Jira project key (defaults to JIRA_PROJECT_KEY env)
|
||||
* @body {string} [issue_type] - Jira issue type name (defaults to JIRA_ISSUE_TYPE env)
|
||||
* @body {string} [source_context] - One of: cve, archer, ivanti_queue, email, manual (default: manual)
|
||||
* @response 201 - { id, ticket_key, jira_url, source_context, message }
|
||||
* @response 207 - { warning, jira_key, jira_url, error } — created in Jira but local save failed
|
||||
* @response 400 - { error: string } — validation error
|
||||
* @response 429 - { error: string } — rate limit exceeded
|
||||
* @response 502 - { error: string, details: object } — Jira API error
|
||||
* @response 503 - { error: string } — Jira not configured
|
||||
*/
|
||||
router.post('/jira/create-in-jira', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
const { cve_id, vendor, summary, description, project_key, issue_type, source_context } = req.body;
|
||||
|
||||
let normalizedCveId = null;
|
||||
if (cve_id !== undefined && cve_id !== null && cve_id !== '') {
|
||||
if (!isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
|
||||
}
|
||||
normalizedCveId = cve_id;
|
||||
}
|
||||
|
||||
let normalizedVendor = null;
|
||||
if (vendor !== undefined && vendor !== null && typeof vendor === 'string' && vendor.trim().length > 0) {
|
||||
const trimmedVendor = vendor.trim();
|
||||
if (trimmedVendor.length > 200) {
|
||||
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
|
||||
}
|
||||
normalizedVendor = trimmedVendor;
|
||||
}
|
||||
|
||||
let normalizedSourceContext = 'manual';
|
||||
if (source_context !== undefined && source_context !== null) {
|
||||
if (!ALLOWED_SOURCE_CONTEXTS.includes(source_context)) {
|
||||
return res.status(400).json({ error: 'source_context must be one of: cve, archer, ivanti_queue, email, manual.' });
|
||||
}
|
||||
normalizedSourceContext = source_context;
|
||||
}
|
||||
|
||||
if (!summary || typeof summary !== 'string' || summary.trim().length === 0 || summary.length > 255) {
|
||||
return res.status(400).json({ error: 'Summary is required (max 255 chars).' });
|
||||
}
|
||||
|
||||
const projectKey = project_key || jiraApi.JIRA_PROJECT_KEY;
|
||||
const issueType = issue_type || jiraApi.JIRA_ISSUE_TYPE;
|
||||
|
||||
if (!projectKey) {
|
||||
return res.status(400).json({ error: 'Project key is required. Set JIRA_PROJECT_KEY in .env or provide project_key in request.' });
|
||||
}
|
||||
|
||||
const fields = {
|
||||
project: { key: projectKey },
|
||||
summary: summary.trim(),
|
||||
issuetype: { name: issueType }
|
||||
};
|
||||
if (description) {
|
||||
fields.description = description;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jiraApi.createIssue(fields);
|
||||
if (!result.ok) {
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
return res.status(502).json({ error: 'Failed to create Jira issue.', details: result.body });
|
||||
}
|
||||
|
||||
const jiraIssue = result.data;
|
||||
const ticketKey = jiraIssue.key;
|
||||
const jiraUrl = jiraIssue.self
|
||||
? jiraIssue.self.replace(/\/rest\/api\/2\/issue\/.*/, `/browse/${ticketKey}`)
|
||||
: null;
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO tickets (ticket_type, cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by, source_context)
|
||||
VALUES ('jira', $1, $2, $3, $4, $5, 'Open', $6, 'Open', NOW(), $7, $8)
|
||||
RETURNING id`,
|
||||
[normalizedCveId, normalizedVendor, ticketKey, jiraUrl, summary.trim(), jiraIssue.id, req.user.id, normalizedSourceContext]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_create_via_api',
|
||||
entityType: 'ticket',
|
||||
entityId: rows[0].id.toString(),
|
||||
details: { ticket_type: 'jira', cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey, source_context: normalizedSourceContext },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: rows[0].id,
|
||||
ticket_key: ticketKey,
|
||||
jira_url: jiraUrl,
|
||||
source_context: normalizedSourceContext,
|
||||
message: 'Jira issue created and linked successfully'
|
||||
});
|
||||
} catch (dbErr) {
|
||||
console.error('Error saving local Jira ticket record:', dbErr);
|
||||
return res.status(207).json({
|
||||
warning: 'Issue created in Jira but local record failed to save.',
|
||||
jira_key: ticketKey,
|
||||
jira_url: jiraUrl,
|
||||
error: dbErr.message
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tickets/jira/sync-all
|
||||
*
|
||||
* Syncs all local Jira-type ticket records with their current Jira status.
|
||||
* Skips tickets already in a closed/completed state. Admin only.
|
||||
*
|
||||
* @response 200 - { synced, failed, skipped, unchanged, errors: string[], skippedCompleted }
|
||||
* @response 500 - { error: string }
|
||||
* @response 503 - { error: string } — Jira not configured
|
||||
*/
|
||||
router.post('/jira/sync-all', requireGroup('Admin'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows: tickets } = await pool.query(
|
||||
"SELECT * FROM tickets WHERE ticket_type = 'jira' AND ticket_key IS NOT NULL AND ticket_key != ''"
|
||||
);
|
||||
|
||||
const CLOSED_STATUSES = ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'];
|
||||
const isCompleted = (status) => {
|
||||
if (!status) return false;
|
||||
const lower = status.toLowerCase();
|
||||
return CLOSED_STATUSES.some(s => lower.includes(s));
|
||||
};
|
||||
|
||||
const activeTickets = tickets.filter(t => !isCompleted(t.status));
|
||||
const skippedCompleted = tickets.length - activeTickets.length;
|
||||
|
||||
if (activeTickets.length === 0) {
|
||||
return res.json({ synced: 0, failed: 0, skipped: skippedCompleted, unchanged: 0, errors: [], skippedCompleted });
|
||||
}
|
||||
|
||||
const results = { synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] };
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
const batches = [];
|
||||
for (let i = 0; i < activeTickets.length; i += BATCH_SIZE) {
|
||||
batches.push(activeTickets.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
const rateStatus = jiraApi.getRateLimitStatus();
|
||||
if (rateStatus.burst.remaining <= 5 || rateStatus.daily.remaining <= 10) {
|
||||
const remaining = activeTickets.length - results.synced - results.failed - results.unchanged;
|
||||
results.skipped += remaining;
|
||||
results.errors.push('Rate limit approaching — stopped sync early to preserve budget.');
|
||||
break;
|
||||
}
|
||||
|
||||
const keys = batch.map(t => t.ticket_key);
|
||||
try {
|
||||
const result = await jiraApi.searchIssuesByKeys(keys);
|
||||
if (!result.ok) {
|
||||
if (result.rateLimited) {
|
||||
results.skipped += batch.length;
|
||||
results.errors.push('Jira rate limit hit during sync.');
|
||||
break;
|
||||
}
|
||||
results.failed += batch.length;
|
||||
results.errors.push(`Batch search failed: HTTP ${result.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const issueMap = {};
|
||||
for (const issue of (result.data.issues || [])) {
|
||||
issueMap[issue.key] = issue;
|
||||
}
|
||||
|
||||
for (const ticket of batch) {
|
||||
const issue = issueMap[ticket.ticket_key];
|
||||
if (!issue) {
|
||||
results.unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
|
||||
[jiraSummary, localStatus, jiraStatus, ticket.id]
|
||||
);
|
||||
results.synced++;
|
||||
} catch (dbErr) {
|
||||
results.failed++;
|
||||
results.errors.push(`${ticket.ticket_key}: DB update failed — ${dbErr.message}`);
|
||||
}
|
||||
}
|
||||
} catch (searchErr) {
|
||||
results.failed += batch.length;
|
||||
results.errors.push(`Batch search error: ${searchErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_sync_all',
|
||||
entityType: 'jira_integration',
|
||||
entityId: null,
|
||||
details: { ...results, skippedCompleted },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ ...results, skippedCompleted });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CRUD endpoints
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/tickets
|
||||
*
|
||||
* Lists all tickets with optional filtering. Returns results ordered by created_at descending.
|
||||
*
|
||||
* @query {string} [type] - Filter by ticket_type ('archer' or 'jira')
|
||||
* @query {string} [cve_id] - Filter by CVE ID
|
||||
* @query {string} [vendor] - Filter by vendor name
|
||||
* @query {string} [status] - Filter by ticket status
|
||||
* @query {string} [source_context] - Filter by source context
|
||||
* @response 200 - ticket[]
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
const { type, cve_id, vendor, status, source_context } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM tickets WHERE 1=1';
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (type) {
|
||||
query += ` AND ticket_type = $${paramIndex++}`;
|
||||
params.push(type);
|
||||
}
|
||||
if (cve_id) {
|
||||
query += ` AND cve_id = $${paramIndex++}`;
|
||||
params.push(cve_id);
|
||||
}
|
||||
if (vendor) {
|
||||
query += ` AND vendor = $${paramIndex++}`;
|
||||
params.push(vendor);
|
||||
}
|
||||
if (status) {
|
||||
query += ` AND status = $${paramIndex++}`;
|
||||
params.push(status);
|
||||
}
|
||||
if (source_context) {
|
||||
query += ` AND source_context = $${paramIndex++}`;
|
||||
params.push(source_context);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(query, params);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('Error fetching tickets:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tickets/:id
|
||||
*
|
||||
* Returns a single ticket by ID.
|
||||
*
|
||||
* @param {string} id - Ticket ID
|
||||
* @response 200 - Ticket object
|
||||
* @response 404 - { error: string }
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM tickets WHERE id = $1', [id]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Ticket not found.' });
|
||||
}
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
console.error('Error fetching ticket:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tickets
|
||||
*
|
||||
* Creates a ticket with type-aware validation.
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @body {string} ticket_type - 'archer' or 'jira' (required)
|
||||
* @body {string} ticket_key - Ticket identifier: EXC-XXXX for Archer, PROJECT-123 for Jira (required)
|
||||
* @body {string} [cve_id] - CVE ID (required for Archer, optional for Jira)
|
||||
* @body {string} [vendor] - Vendor name (required for Archer, optional for Jira, max 200 chars)
|
||||
* @body {string} [url] - External ticket URL (max 500 chars)
|
||||
* @body {string} [summary] - Ticket summary text (max 500 chars)
|
||||
* @body {string} [status] - Ticket status (Archer: Draft/Open/Under Review/Accepted; Jira: free-form)
|
||||
* @body {string} [source_context] - One of: cve, archer, ivanti_queue, email, manual (default: manual)
|
||||
* @response 201 - { id, message }
|
||||
* @response 400 - { error: string } — validation error
|
||||
* @response 409 - { error: string } — duplicate ticket_key
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.post('/', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { ticket_type, ticket_key, cve_id, vendor, url, summary, status, source_context } = req.body;
|
||||
|
||||
// Validate ticket_type
|
||||
if (!ticket_type || !['archer', 'jira'].includes(ticket_type)) {
|
||||
return res.status(400).json({ error: "ticket_type must be 'archer' or 'jira'." });
|
||||
}
|
||||
|
||||
// Validate ticket_key based on type
|
||||
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'ticket_key is required.' });
|
||||
}
|
||||
|
||||
if (ticket_type === 'archer') {
|
||||
if (!ARCHER_KEY_PATTERN.test(ticket_key.trim())) {
|
||||
return res.status(400).json({ error: 'ticket_key must be in format EXC-XXXX for Archer tickets.' });
|
||||
}
|
||||
// Archer requires cve_id and vendor
|
||||
if (!cve_id || !isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'Valid CVE ID is required for Archer tickets.' });
|
||||
}
|
||||
if (!vendor || !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'Valid vendor is required for Archer tickets.' });
|
||||
}
|
||||
if (status && !ARCHER_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Invalid status for Archer ticket. Must be one of: ${ARCHER_STATUSES.join(', ')}.` });
|
||||
}
|
||||
} else {
|
||||
// Jira type
|
||||
if (!JIRA_KEY_PATTERN.test(ticket_key.trim())) {
|
||||
return res.status(400).json({ error: 'ticket_key must be in format PROJECT-123 for Jira tickets.' });
|
||||
}
|
||||
// Jira allows nullable cve_id/vendor but validates format if provided
|
||||
if (cve_id !== undefined && cve_id !== null && cve_id !== '' && !isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
|
||||
}
|
||||
if (vendor !== undefined && vendor !== null && typeof vendor === 'string' && vendor.trim().length > 200) {
|
||||
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
|
||||
}
|
||||
}
|
||||
|
||||
// Common validations
|
||||
if (url && (typeof url !== 'string' || url.length > 500)) {
|
||||
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||
}
|
||||
if (summary && (typeof summary !== 'string' || summary.length > 500)) {
|
||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||
}
|
||||
|
||||
let normalizedSourceContext = 'manual';
|
||||
if (source_context !== undefined && source_context !== null) {
|
||||
if (!ALLOWED_SOURCE_CONTEXTS.includes(source_context)) {
|
||||
return res.status(400).json({ error: 'source_context must be one of: cve, archer, ivanti_queue, email, manual.' });
|
||||
}
|
||||
normalizedSourceContext = source_context;
|
||||
}
|
||||
|
||||
const ticketStatus = status || (ticket_type === 'archer' ? 'Draft' : 'Open');
|
||||
const normalizedCveId = (cve_id && typeof cve_id === 'string' && cve_id.trim().length > 0) ? cve_id : null;
|
||||
const normalizedVendor = (vendor && typeof vendor === 'string' && vendor.trim().length > 0) ? vendor.trim() : null;
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO tickets (ticket_type, ticket_key, cve_id, vendor, url, summary, status, source_context, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id`,
|
||||
[ticket_type, ticket_key.trim(), normalizedCveId, normalizedVendor, url || null, summary || null, ticketStatus, normalizedSourceContext, req.user.id]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: `${ticket_type}_ticket_create`,
|
||||
entityType: 'ticket',
|
||||
entityId: rows[0].id.toString(),
|
||||
details: { ticket_type, ticket_key, cve_id: normalizedCveId, vendor: normalizedVendor, status: ticketStatus },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: rows[0].id,
|
||||
message: 'Ticket created successfully'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error creating ticket:', err);
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({ error: 'A ticket with this key already exists.' });
|
||||
}
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/tickets/:id
|
||||
*
|
||||
* Updates ticket fields with type-aware validation.
|
||||
* Requires Admin or Standard_User group. source_context is immutable after creation.
|
||||
*
|
||||
* @param {string} id - Ticket ID
|
||||
* @body {string} [ticket_key] - Updated ticket key (validated per type)
|
||||
* @body {string|null} [cve_id] - Updated CVE ID (CVE-YYYY-NNNNN format or null)
|
||||
* @body {string|null} [vendor] - Updated vendor name (max 200 chars or null)
|
||||
* @body {string|null} [url] - Updated URL (max 500 chars)
|
||||
* @body {string|null} [summary] - Updated summary (max 500 chars)
|
||||
* @body {string} [status] - Updated status (Archer: restricted set; Jira: free-form)
|
||||
* @response 200 - { message, changes: number }
|
||||
* @response 400 - { error: string } — validation error or no fields
|
||||
* @response 404 - { error: string } — ticket not found
|
||||
* @response 409 - { error: string } — duplicate ticket_key
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.put('/:id', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ticket_key, cve_id, vendor, url, summary, status } = req.body;
|
||||
|
||||
// source_context is immutable after creation
|
||||
if ('source_context' in req.body) {
|
||||
return res.status(400).json({ error: 'source_context is immutable after creation.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM tickets WHERE id = $1', [id]);
|
||||
const existing = rows[0];
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Ticket not found.' });
|
||||
}
|
||||
|
||||
// Type-aware validation
|
||||
if (existing.ticket_type === 'archer') {
|
||||
if (ticket_key !== undefined && !ARCHER_KEY_PATTERN.test(ticket_key.trim())) {
|
||||
return res.status(400).json({ error: 'ticket_key must be in format EXC-XXXX for Archer tickets.' });
|
||||
}
|
||||
if (status !== undefined && !ARCHER_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Invalid status for Archer ticket. Must be one of: ${ARCHER_STATUSES.join(', ')}.` });
|
||||
}
|
||||
} else {
|
||||
// Jira — free-form status, validate key format if changed
|
||||
if (ticket_key !== undefined && !JIRA_KEY_PATTERN.test(ticket_key.trim())) {
|
||||
return res.status(400).json({ error: 'ticket_key must be in format PROJECT-123 for Jira tickets.' });
|
||||
}
|
||||
}
|
||||
|
||||
// Common validations
|
||||
if (cve_id !== undefined && cve_id !== null && cve_id !== '' && !isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
|
||||
}
|
||||
if (vendor !== undefined && vendor !== null && typeof vendor === 'string' && vendor.trim().length > 200) {
|
||||
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
|
||||
}
|
||||
if (url !== undefined && url !== null && (typeof url !== 'string' || url.length > 500)) {
|
||||
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||
}
|
||||
if (summary !== undefined && summary !== null && (typeof summary !== 'string' || summary.length > 500)) {
|
||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||
}
|
||||
if (status !== undefined && typeof status !== 'string') {
|
||||
return res.status(400).json({ error: 'Status must be a string.' });
|
||||
}
|
||||
|
||||
const fields = [];
|
||||
const values = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (ticket_key !== undefined) { fields.push(`ticket_key = $${paramIndex++}`); values.push(ticket_key.trim()); }
|
||||
if (cve_id !== undefined) { fields.push(`cve_id = $${paramIndex++}`); values.push(cve_id || null); }
|
||||
if (vendor !== undefined) { fields.push(`vendor = $${paramIndex++}`); values.push(vendor ? vendor.trim() : null); }
|
||||
if (url !== undefined) { fields.push(`url = $${paramIndex++}`); values.push(url); }
|
||||
if (summary !== undefined) { fields.push(`summary = $${paramIndex++}`); values.push(summary); }
|
||||
if (status !== undefined) { fields.push(`status = $${paramIndex++}`); values.push(status); }
|
||||
|
||||
if (fields.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update.' });
|
||||
}
|
||||
|
||||
fields.push('updated_at = NOW()');
|
||||
values.push(id);
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE tickets SET ${fields.join(', ')} WHERE id = $${paramIndex}`,
|
||||
values
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: `${existing.ticket_type}_ticket_update`,
|
||||
entityType: 'ticket',
|
||||
entityId: id,
|
||||
details: { ticket_type: existing.ticket_type, before: existing, changes: req.body },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'Ticket updated successfully', changes: result.rowCount });
|
||||
} catch (err) {
|
||||
console.error('Error updating ticket:', err);
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({ error: 'A ticket with this key already exists.' });
|
||||
}
|
||||
res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/tickets/:id
|
||||
*
|
||||
* Deletes a ticket with type-aware authorization.
|
||||
* Admin bypasses all restrictions. Standard_User must own the ticket and
|
||||
* the ticket must not be linked to active compliance items.
|
||||
* Jira-type tickets cascade-delete associated queue junction rows.
|
||||
*
|
||||
* @param {string} id - Ticket ID
|
||||
* @response 200 - { message }
|
||||
* @response 403 - { error: string } — ownership or compliance linkage violation
|
||||
* @response 404 - { error: string } — ticket not found
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.delete('/:id', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM tickets WHERE id = $1', [id]);
|
||||
const ticket = rows[0];
|
||||
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'Ticket not found.' });
|
||||
}
|
||||
|
||||
// Admin bypasses all delete restrictions
|
||||
if (req.user.group === 'Admin') {
|
||||
return performDelete();
|
||||
}
|
||||
|
||||
// Standard_User: ownership check
|
||||
if (ticket.created_by && ticket.created_by !== req.user.id) {
|
||||
return res.status(403).json({ error: 'You can only delete resources you created.' });
|
||||
}
|
||||
|
||||
// Type-aware compliance linkage check
|
||||
const ticketKey = ticket.ticket_key;
|
||||
try {
|
||||
const { rows: compLinks } = await pool.query(
|
||||
`SELECT ci.id, ci.extra_json
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||
WHERE ci.status = 'active' AND ci.extra_json ILIKE $1`,
|
||||
[`%${ticketKey}%`]
|
||||
);
|
||||
|
||||
const isLinked = (compLinks || []).some(cl => {
|
||||
const json = cl.extra_json || '';
|
||||
return json.includes(ticketKey);
|
||||
});
|
||||
|
||||
if (isLinked) {
|
||||
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
|
||||
}
|
||||
} catch (compErr) {
|
||||
if (!compErr.message.includes('does not exist')) throw compErr;
|
||||
}
|
||||
|
||||
return performDelete();
|
||||
|
||||
async function performDelete() {
|
||||
// For Jira tickets, cascade delete junction rows
|
||||
if (ticket.ticket_type === 'jira') {
|
||||
await pool.query('DELETE FROM jira_ticket_queue_items WHERE jira_ticket_id = $1', [id]);
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM tickets WHERE id = $1', [id]);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: `${ticket.ticket_type}_ticket_delete`,
|
||||
entityType: 'ticket',
|
||||
entityId: id,
|
||||
details: { ticket_type: ticket.ticket_type, ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'Ticket deleted successfully' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting ticket:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Jira sync (single ticket) and queue-items link
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* POST /api/tickets/:id/sync
|
||||
*
|
||||
* Syncs a single Jira-type ticket with its current Jira status.
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {string} id - Ticket ID (must be a jira-type ticket)
|
||||
* @response 200 - { message, ticket_key, jira_status, local_status, summary }
|
||||
* @response 400 - { error: string } — not a Jira ticket or missing key
|
||||
* @response 404 - { error: string } — ticket not found
|
||||
* @response 429 - { error: string } — rate limit exceeded
|
||||
* @response 502 - { error: string, details: object } — Jira API error
|
||||
* @response 503 - { error: string } — Jira not configured
|
||||
*/
|
||||
router.post('/:id/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM tickets WHERE id = $1', [id]);
|
||||
const ticket = rows[0];
|
||||
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'Ticket not found.' });
|
||||
}
|
||||
if (ticket.ticket_type !== 'jira') {
|
||||
return res.status(400).json({ error: 'Only Jira-type tickets can be synced.' });
|
||||
}
|
||||
if (!ticket.ticket_key) {
|
||||
return res.status(400).json({ error: 'Ticket has no Jira key to sync.' });
|
||||
}
|
||||
|
||||
const result = await jiraApi.getIssue(ticket.ticket_key);
|
||||
if (!result.ok) {
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
return res.status(502).json({ error: 'Failed to fetch issue from Jira.', details: result.body });
|
||||
}
|
||||
|
||||
const issue = result.data;
|
||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||
|
||||
await pool.query(
|
||||
`UPDATE tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
|
||||
[jiraSummary, localStatus, jiraStatus, id]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_sync',
|
||||
entityType: 'ticket',
|
||||
entityId: id,
|
||||
details: { ticket_key: ticket.ticket_key, jira_status: jiraStatus, local_status: localStatus },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Ticket synced with Jira',
|
||||
ticket_key: ticket.ticket_key,
|
||||
jira_status: jiraStatus,
|
||||
local_status: localStatus,
|
||||
summary: jiraSummary
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tickets/:id/queue-items
|
||||
*
|
||||
* Links Ivanti todo queue items to a ticket via the junction table.
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {string} id - Ticket ID
|
||||
* @body {number[]} queue_item_ids - Array of ivanti_todo_queue IDs to link (required, non-empty)
|
||||
* @response 201 - { message, ticket_id, linked_count }
|
||||
* @response 400 - { error: string } — invalid input or queue items not found
|
||||
* @response 404 - { error: string } — ticket not found
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.post('/:id/queue-items', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { queue_item_ids } = req.body;
|
||||
|
||||
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 {
|
||||
const { rows: ticketRows } = await pool.query('SELECT id, ticket_type FROM tickets WHERE id = $1', [id]);
|
||||
if (ticketRows.length === 0) {
|
||||
return res.status(404).json({ error: 'Ticket not found.' });
|
||||
}
|
||||
|
||||
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.' });
|
||||
}
|
||||
|
||||
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: 'ticket_link_queue_items',
|
||||
entityType: '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 ticket:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createTicketsRouter;
|
||||
@@ -42,6 +42,7 @@ const createFeedbackRouter = require('./routes/feedback');
|
||||
const createWebhooksRouter = require('./routes/webhooks');
|
||||
const createNotificationsRouter = require('./routes/notifications');
|
||||
const createNetboxRouter = require('./routes/netbox');
|
||||
const createTicketsRouter = require('./routes/tickets');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -277,6 +278,9 @@ app.use('/api/atlas', createAtlasRouter());
|
||||
// Jira ticket routes — local CRUD + Jira REST API integration (lookup, sync, create)
|
||||
app.use('/api/jira-tickets', createJiraTicketsRouter());
|
||||
|
||||
// Unified tickets routes — single table for both Archer and Jira tickets
|
||||
app.use('/api/tickets', createTicketsRouter());
|
||||
|
||||
// CARD Asset Ownership API routes — proxy CARD operations, mutation flow, asset search
|
||||
app.use('/api/card', createCardApiRouter());
|
||||
|
||||
@@ -753,14 +757,14 @@ app.delete('/api/cves/by-cve-id/:cveId', requireAuth(), requireGroup('Admin', 'S
|
||||
|
||||
// Cascade impact check for Standard_User
|
||||
const { rows: archerTickets } = await pool.query(
|
||||
'SELECT id, exc_number, cve_id, vendor FROM archer_tickets WHERE cve_id = $1',
|
||||
"SELECT id, ticket_key AS exc_number, cve_id, vendor FROM tickets WHERE ticket_type = 'archer' AND cve_id = $1",
|
||||
[cveId]
|
||||
);
|
||||
|
||||
let jiraTickets = [];
|
||||
try {
|
||||
const jiraResult = await pool.query(
|
||||
'SELECT id, cve_id, vendor, ticket_key, status FROM jira_tickets WHERE cve_id = $1',
|
||||
"SELECT id, cve_id, vendor, ticket_key, status FROM tickets WHERE ticket_type = 'jira' AND cve_id = $1",
|
||||
[cveId]
|
||||
);
|
||||
jiraTickets = jiraResult.rows;
|
||||
@@ -931,14 +935,14 @@ app.delete('/api/cves/:id', requireAuth(), requireGroup('Admin', 'Standard_User'
|
||||
// Cascade/compliance check for Standard_User
|
||||
if (req.user.group === 'Standard_User') {
|
||||
const { rows: archerTickets } = await pool.query(
|
||||
'SELECT id, exc_number FROM archer_tickets WHERE cve_id = $1 AND vendor = $2',
|
||||
"SELECT id, ticket_key AS exc_number FROM tickets WHERE ticket_type = 'archer' AND cve_id = $1 AND vendor = $2",
|
||||
[cve.cve_id, cve.vendor]
|
||||
);
|
||||
|
||||
let jiraTickets = [];
|
||||
try {
|
||||
const jiraResult = await pool.query(
|
||||
'SELECT id, ticket_key FROM jira_tickets WHERE cve_id = $1 AND vendor = $2',
|
||||
"SELECT id, ticket_key FROM tickets WHERE ticket_type = 'jira' AND cve_id = $1 AND vendor = $2",
|
||||
[cve.cve_id, cve.vendor]
|
||||
);
|
||||
jiraTickets = jiraResult.rows;
|
||||
|
||||
Reference in New Issue
Block a user