2026-04-28 16:36:54 +00:00
|
|
|
// 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)
|
|
|
|
|
|
|
|
|
|
const express = require('express');
|
2026-05-06 11:44:17 -06:00
|
|
|
const pool = require('../db');
|
2026-04-28 16:36:54 +00:00
|
|
|
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 VALID_TICKET_STATUSES = ['Open', 'In Progress', 'Closed'];
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
function createJiraTicketsRouter() {
|
2026-04-28 16:36:54 +00:00
|
|
|
const router = express.Router();
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
// Jira API integration endpoints
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
|
2026-05-21 15:06:16 -06:00
|
|
|
/**
|
|
|
|
|
* GET /api/jira-tickets/connection-test
|
|
|
|
|
*
|
|
|
|
|
* Tests connectivity to the configured Jira instance.
|
|
|
|
|
*
|
|
|
|
|
* @requires Admin group
|
|
|
|
|
* @returns {object} 200 - { connected: true, user: { name, displayName, ... } }
|
|
|
|
|
* @returns {object} 502 - { connected: false, error: string } on connection failure
|
|
|
|
|
* @returns {object} 503 - { error: string } when Jira API is not configured
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.get('/connection-test', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
2026-04-28 16:36:54 +00:00
|
|
|
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) {
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({
|
2026-04-28 16:36:54 +00:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-21 15:06:16 -06:00
|
|
|
/**
|
|
|
|
|
* GET /api/jira-tickets/rate-limit
|
|
|
|
|
*
|
|
|
|
|
* Returns the current Jira API rate limit status (burst and daily counters).
|
|
|
|
|
*
|
|
|
|
|
* @requires Admin group
|
|
|
|
|
* @returns {object} 200 - { burst: { remaining, limit, ... }, daily: { remaining, limit, ... } }
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.get('/rate-limit', requireAuth(), requireGroup('Admin'), (req, res) => {
|
2026-04-28 16:36:54 +00:00
|
|
|
res.json(jiraApi.getRateLimitStatus());
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-21 15:06:16 -06:00
|
|
|
/**
|
|
|
|
|
* GET /api/jira-tickets/lookup/:issueKey
|
|
|
|
|
*
|
|
|
|
|
* Looks up a single Jira issue by its key (e.g., PROJECT-123) and returns
|
|
|
|
|
* a summary of its fields.
|
|
|
|
|
*
|
|
|
|
|
* @param {string} issueKey - Jira issue key (path parameter, format: PROJECT-123)
|
|
|
|
|
* @requires Authenticated user
|
|
|
|
|
* @returns {object} 200 - { key, summary, status, assignee, priority, issuetype, created, updated, self }
|
|
|
|
|
* @returns {object} 400 - { error: string } for invalid issue key format
|
|
|
|
|
* @returns {object} 404 - { error: string } when issue not found in Jira
|
|
|
|
|
* @returns {object} 429 - { error: string } when Jira rate limit exceeded
|
|
|
|
|
* @returns {object} 502 - { error: string } on Jira API error
|
|
|
|
|
* @returns {object} 503 - { error: string } when Jira API is not configured
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.get('/lookup/:issueKey', requireAuth(), async (req, res) => {
|
2026-04-28 16:36:54 +00:00
|
|
|
if (!jiraApi.isConfigured) {
|
|
|
|
|
return res.status(503).json({ error: 'Jira API is not configured.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { issueKey } = req.params;
|
|
|
|
|
if (!issueKey || !/^[A-Z][A-Z0-9_]+-\d+$/.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.' });
|
|
|
|
|
}
|
2026-05-22 09:55:14 -06:00
|
|
|
// 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 {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-28 16:36:54 +00:00
|
|
|
return res.status(result.status === 404 ? 404 : 502).json({
|
2026-05-22 09:55:14 -06:00
|
|
|
error: errorMsg,
|
2026-04-28 16:36:54 +00:00
|
|
|
details: result.body
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return res.status(502).json({ error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-21 15:06:16 -06:00
|
|
|
/**
|
|
|
|
|
* POST /api/jira-tickets/create-in-jira
|
|
|
|
|
*
|
|
|
|
|
* Creates a new issue in Jira and saves a local tracking record.
|
|
|
|
|
*
|
|
|
|
|
* @requires Admin or Standard_User group
|
|
|
|
|
* @body {string} [cve_id] - Optional CVE ID (format: CVE-YYYY-NNNN+); stored as NULL if absent/empty
|
|
|
|
|
* @body {string} [vendor] - Optional vendor name (max 200 chars after trim); stored as NULL if absent/empty/whitespace
|
|
|
|
|
* @body {string} summary - Required issue summary (max 255 chars)
|
|
|
|
|
* @body {string} [description] - Optional issue description
|
|
|
|
|
* @body {string} [project_key] - Jira project key (defaults to JIRA_PROJECT_KEY env var)
|
|
|
|
|
* @body {string} [issue_type] - Jira issue type name (defaults to JIRA_ISSUE_TYPE env var)
|
|
|
|
|
* @body {string} [source_context] - One of: cve, archer, ivanti_queue, email, manual (defaults to 'manual')
|
|
|
|
|
* @returns {object} 201 - { id, ticket_key, jira_url, source_context, message }
|
|
|
|
|
* @returns {object} 207 - { warning, jira_key, jira_url, error } when Jira issue created but local DB save failed
|
|
|
|
|
* @returns {object} 400 - { error: string } for validation failures
|
|
|
|
|
* @returns {object} 429 - { error: string } when Jira rate limit exceeded
|
|
|
|
|
* @returns {object} 502 - { error: string } on Jira API error
|
|
|
|
|
* @returns {object} 503 - { error: string } when Jira API is not configured
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.post('/create-in-jira', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
2026-04-28 16:36:54 +00:00
|
|
|
if (!jiraApi.isConfigured) {
|
|
|
|
|
return res.status(503).json({ error: 'Jira API is not configured.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 15:06:16 -06:00
|
|
|
const { cve_id, vendor, summary, description, project_key, issue_type, source_context } = req.body;
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-21 15:06:16 -06:00
|
|
|
// --- 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)) {
|
|
|
|
|
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
|
|
|
|
|
}
|
|
|
|
|
normalizedCveId = cve_id;
|
2026-04-28 16:36:54 +00:00
|
|
|
}
|
2026-05-21 15:06:16 -06:00
|
|
|
|
|
|
|
|
// --- 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();
|
|
|
|
|
if (trimmedVendor.length > 200) {
|
|
|
|
|
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
|
|
|
|
|
}
|
|
|
|
|
normalizedVendor = trimmedVendor;
|
2026-04-28 16:36:54 +00:00
|
|
|
}
|
2026-05-21 15:06:16 -06:00
|
|
|
|
|
|
|
|
// --- 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) {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Summary validation: required, non-empty, max 255 chars ---
|
2026-04-28 16:36:54 +00:00
|
|
|
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;
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query(
|
2026-05-21 15:06:16 -06:00
|
|
|
`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)
|
2026-05-06 11:44:17 -06:00
|
|
|
RETURNING id`,
|
2026-05-21 15:06:16 -06:00
|
|
|
[normalizedCveId, normalizedVendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id, normalizedSourceContext]
|
2026-05-06 11:44:17 -06:00
|
|
|
);
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({
|
|
|
|
|
userId: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
action: 'jira_ticket_create_via_api',
|
|
|
|
|
entityType: 'jira_ticket',
|
|
|
|
|
entityId: rows[0].id.toString(),
|
2026-05-21 15:06:16 -06:00
|
|
|
details: { cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey, source_context: normalizedSourceContext },
|
2026-05-06 11:44:17 -06:00
|
|
|
ipAddress: req.ip
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.status(201).json({
|
|
|
|
|
id: rows[0].id,
|
|
|
|
|
ticket_key: ticketKey,
|
|
|
|
|
jira_url: jiraUrl,
|
2026-05-21 15:06:16 -06:00
|
|
|
source_context: normalizedSourceContext,
|
2026-05-06 11:44:17 -06:00
|
|
|
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
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-28 16:36:54 +00:00
|
|
|
} catch (err) {
|
|
|
|
|
return res.status(502).json({ error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-21 15:06:16 -06:00
|
|
|
/**
|
|
|
|
|
* POST /api/jira-tickets/sync-all
|
|
|
|
|
*
|
|
|
|
|
* Syncs all local Jira ticket records with their current Jira status using
|
|
|
|
|
* bulk JQL search. Updates summary, status, and last_synced_at for each ticket.
|
|
|
|
|
* Stops early if rate limits are approaching.
|
|
|
|
|
*
|
|
|
|
|
* @requires Admin group
|
|
|
|
|
* @returns {object} 200 - { synced, failed, skipped, unchanged, errors: string[] }
|
|
|
|
|
* @returns {object} 500 - { error: string } on internal error
|
|
|
|
|
* @returns {object} 503 - { error: string } when Jira API is not configured
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.post('/sync-all', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
2026-04-28 16:36:54 +00:00
|
|
|
if (!jiraApi.isConfigured) {
|
|
|
|
|
return res.status(503).json({ error: 'Jira API is not configured.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
const { rows: tickets } = await pool.query(
|
|
|
|
|
"SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''"
|
|
|
|
|
);
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
if (tickets.length === 0) {
|
|
|
|
|
return res.json({ synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const results = { synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] };
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
const BATCH_SIZE = 100;
|
|
|
|
|
const batches = [];
|
|
|
|
|
for (let i = 0; i < tickets.length; i += BATCH_SIZE) {
|
|
|
|
|
batches.push(tickets.slice(i, i + BATCH_SIZE));
|
|
|
|
|
}
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
for (const batch of batches) {
|
|
|
|
|
const rateStatus = jiraApi.getRateLimitStatus();
|
|
|
|
|
if (rateStatus.burst.remaining <= 5 || rateStatus.daily.remaining <= 10) {
|
|
|
|
|
const remaining = tickets.length - results.synced - results.failed - results.unchanged;
|
|
|
|
|
results.skipped += remaining;
|
|
|
|
|
results.errors.push('Rate limit approaching — stopped sync early to preserve budget.');
|
|
|
|
|
break;
|
2026-04-28 16:36:54 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
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;
|
2026-04-28 16:36:54 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
const issueMap = {};
|
|
|
|
|
for (const issue of (result.data.issues || [])) {
|
|
|
|
|
issueMap[issue.key] = issue;
|
|
|
|
|
}
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
for (const ticket of batch) {
|
|
|
|
|
const issue = issueMap[ticket.ticket_key];
|
|
|
|
|
if (!issue) {
|
|
|
|
|
results.unchanged++;
|
|
|
|
|
continue;
|
2026-04-28 16:36:54 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
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, localStatus, jiraStatus, ticket.id]
|
|
|
|
|
);
|
|
|
|
|
results.synced++;
|
|
|
|
|
} catch (dbErr) {
|
|
|
|
|
results.failed++;
|
|
|
|
|
results.errors.push(`${ticket.ticket_key}: DB update failed — ${dbErr.message}`);
|
2026-04-28 16:36:54 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-06 11:44:17 -06:00
|
|
|
} catch (searchErr) {
|
|
|
|
|
results.failed += batch.length;
|
|
|
|
|
results.errors.push(`Batch search error: ${searchErr.message}`);
|
2026-04-28 16:36:54 +00:00
|
|
|
}
|
2026-05-06 11:44:17 -06:00
|
|
|
}
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({
|
|
|
|
|
userId: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
action: 'jira_sync_all',
|
|
|
|
|
entityType: 'jira_integration',
|
|
|
|
|
entityId: null,
|
|
|
|
|
details: results,
|
|
|
|
|
ipAddress: req.ip
|
|
|
|
|
});
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
res.json(results);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
2026-05-22 10:12:35 -06:00
|
|
|
return res.status(500).json({ error: err.message || 'Internal server error.' });
|
2026-05-06 11:44:17 -06:00
|
|
|
}
|
2026-04-28 16:36:54 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-21 15:06:16 -06:00
|
|
|
/**
|
|
|
|
|
* POST /api/jira-tickets/:id/sync
|
|
|
|
|
*
|
|
|
|
|
* Syncs a single local Jira ticket record with its current Jira status.
|
|
|
|
|
* Fetches the issue by ticket_key and updates summary, status, and last_synced_at.
|
|
|
|
|
*
|
|
|
|
|
* @param {string} id - Local ticket ID (path parameter)
|
|
|
|
|
* @requires Admin or Standard_User group
|
|
|
|
|
* @returns {object} 200 - { message, ticket_key, jira_status, local_status, summary }
|
|
|
|
|
* @returns {object} 400 - { error: string } when ticket has no Jira key
|
|
|
|
|
* @returns {object} 404 - { error: string } when local ticket not found
|
|
|
|
|
* @returns {object} 429 - { error: string } when Jira rate limit exceeded
|
|
|
|
|
* @returns {object} 500 - { error: string } on internal error
|
|
|
|
|
* @returns {object} 502 - { error: string } on Jira API error
|
|
|
|
|
* @returns {object} 503 - { error: string } when Jira API is not configured
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.post('/:id/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
2026-04-28 16:36:54 +00:00
|
|
|
if (!jiraApi.isConfigured) {
|
|
|
|
|
return res.status(503).json({ error: 'Jira API is not configured.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { id } = req.params;
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
|
|
|
|
|
const ticket = rows[0];
|
|
|
|
|
|
2026-04-28 16:36:54 +00:00
|
|
|
if (!ticket) {
|
|
|
|
|
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
|
|
|
|
}
|
|
|
|
|
if (!ticket.ticket_key) {
|
|
|
|
|
return res.status(400).json({ error: 'Ticket has no Jira key to sync.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
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.' });
|
2026-04-28 16:36:54 +00:00
|
|
|
}
|
2026-05-06 11:44:17 -06:00
|
|
|
return res.status(502).json({ error: 'Failed to fetch issue from Jira.', details: result.body });
|
|
|
|
|
}
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
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);
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
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, localStatus, jiraStatus, id]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
logAudit({
|
|
|
|
|
userId: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
action: 'jira_ticket_sync',
|
|
|
|
|
entityType: 'jira_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);
|
2026-05-22 10:12:35 -06:00
|
|
|
return res.status(500).json({ error: err.message || 'Internal server error.' });
|
2026-05-06 11:44:17 -06:00
|
|
|
}
|
2026-04-28 16:36:54 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------
|
2026-05-06 11:44:17 -06:00
|
|
|
// Local CRUD endpoints
|
2026-04-28 16:36:54 +00:00
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
|
2026-05-21 15:06:16 -06:00
|
|
|
/**
|
|
|
|
|
* GET /api/jira-tickets
|
|
|
|
|
*
|
|
|
|
|
* Lists all Jira tickets with optional filtering by query parameters.
|
|
|
|
|
* Results are ordered by created_at descending.
|
|
|
|
|
*
|
|
|
|
|
* @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} [source_context] - Filter by source context (cve, archer, ivanti_queue, email, manual)
|
|
|
|
|
* @requires Authenticated user
|
|
|
|
|
* @returns {array} 200 - Array of jira_tickets rows
|
|
|
|
|
* @returns {object} 500 - { error: string } on internal error
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.get('/', requireAuth(), async (req, res) => {
|
2026-05-21 15:06:16 -06:00
|
|
|
const { cve_id, vendor, status, source_context } = req.query;
|
2026-04-28 16:36:54 +00:00
|
|
|
|
|
|
|
|
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
|
|
|
|
const params = [];
|
2026-05-06 11:44:17 -06:00
|
|
|
let paramIndex = 1;
|
2026-04-28 16:36:54 +00:00
|
|
|
|
|
|
|
|
if (cve_id) {
|
2026-05-06 11:44:17 -06:00
|
|
|
query += ` AND cve_id = $${paramIndex++}`;
|
2026-04-28 16:36:54 +00:00
|
|
|
params.push(cve_id);
|
|
|
|
|
}
|
|
|
|
|
if (vendor) {
|
2026-05-06 11:44:17 -06:00
|
|
|
query += ` AND vendor = $${paramIndex++}`;
|
2026-04-28 16:36:54 +00:00
|
|
|
params.push(vendor);
|
|
|
|
|
}
|
|
|
|
|
if (status) {
|
2026-05-06 11:44:17 -06:00
|
|
|
query += ` AND status = $${paramIndex++}`;
|
2026-04-28 16:36:54 +00:00
|
|
|
params.push(status);
|
|
|
|
|
}
|
2026-05-21 15:06:16 -06:00
|
|
|
if (source_context) {
|
|
|
|
|
query += ` AND source_context = $${paramIndex++}`;
|
|
|
|
|
params.push(source_context);
|
|
|
|
|
}
|
2026-04-28 16:36:54 +00:00
|
|
|
|
|
|
|
|
query += ' ORDER BY created_at DESC';
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query(query, params);
|
2026-04-28 16:36:54 +00:00
|
|
|
res.json(rows);
|
2026-05-06 11:44:17 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error fetching JIRA tickets:', err);
|
2026-05-22 10:12:35 -06:00
|
|
|
res.status(500).json({ error: err.message || 'Internal server error.' });
|
2026-05-06 11:44:17 -06:00
|
|
|
}
|
2026-04-28 16:36:54 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-21 15:06:16 -06:00
|
|
|
/**
|
|
|
|
|
* POST /api/jira-tickets
|
|
|
|
|
*
|
|
|
|
|
* Creates a local Jira ticket record (without creating an issue in Jira).
|
|
|
|
|
* 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} 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)
|
|
|
|
|
* @returns {object} 201 - { id, message }
|
|
|
|
|
* @returns {object} 400 - { error: string } for validation failures
|
|
|
|
|
* @returns {object} 500 - { error: string } on internal error
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
2026-04-28 16:36:54 +00:00
|
|
|
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
|
|
|
|
|
2026-05-21 16:01:31 -06:00
|
|
|
// 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)) {
|
|
|
|
|
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
|
|
|
|
|
}
|
|
|
|
|
normalizedCveId = cve_id;
|
2026-04-28 16:36:54 +00:00
|
|
|
}
|
2026-05-21 16:01:31 -06:00
|
|
|
// 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) {
|
|
|
|
|
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
|
|
|
|
|
}
|
|
|
|
|
normalizedVendor = vendor.trim();
|
2026-04-28 16:36:54 +00:00
|
|
|
}
|
|
|
|
|
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
|
|
|
|
|
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
|
|
|
|
|
}
|
|
|
|
|
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.' });
|
|
|
|
|
}
|
|
|
|
|
if (status && !VALID_TICKET_STATUSES.includes(status)) {
|
|
|
|
|
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ticketStatus = status || 'Open';
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
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)
|
|
|
|
|
RETURNING id`,
|
2026-05-21 16:01:31 -06:00
|
|
|
[normalizedCveId, normalizedVendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id]
|
2026-05-06 11:44:17 -06:00
|
|
|
);
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({
|
|
|
|
|
userId: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
action: 'jira_ticket_create',
|
|
|
|
|
entityType: 'jira_ticket',
|
|
|
|
|
entityId: rows[0].id.toString(),
|
2026-05-21 16:01:31 -06:00
|
|
|
details: { cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key, status: ticketStatus },
|
2026-05-06 11:44:17 -06:00
|
|
|
ipAddress: req.ip
|
|
|
|
|
});
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
res.status(201).json({
|
|
|
|
|
id: rows[0].id,
|
|
|
|
|
message: 'JIRA ticket created successfully'
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error creating JIRA ticket:', err);
|
2026-05-22 10:12:35 -06:00
|
|
|
res.status(500).json({ error: `Failed to save ticket: ${err.message}` });
|
2026-05-06 11:44:17 -06:00
|
|
|
}
|
2026-04-28 16:36:54 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-21 15:06:16 -06:00
|
|
|
/**
|
|
|
|
|
* PUT /api/jira-tickets/:id
|
|
|
|
|
*
|
|
|
|
|
* Updates an existing local Jira ticket record. Only provided fields are updated.
|
|
|
|
|
* The source_context field is immutable after creation — including it returns 400.
|
|
|
|
|
*
|
|
|
|
|
* @param {string} id - Local ticket ID (path parameter)
|
|
|
|
|
* @requires Admin or Standard_User group
|
|
|
|
|
* @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
|
|
|
|
|
* @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} 500 - { error: string } on internal error
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
2026-04-28 16:36:54 +00:00
|
|
|
const { id } = req.params;
|
|
|
|
|
const { ticket_key, url, summary, status } = req.body;
|
|
|
|
|
|
2026-05-21 15:06:16 -06:00
|
|
|
// 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' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 16:36:54 +00:00
|
|
|
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
|
|
|
|
|
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
|
|
|
|
|
}
|
|
|
|
|
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 && !VALID_TICKET_STATUSES.includes(status)) {
|
|
|
|
|
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fields = [];
|
|
|
|
|
const values = [];
|
2026-05-06 11:44:17 -06:00
|
|
|
let paramIndex = 1;
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
if (ticket_key !== undefined) { fields.push(`ticket_key = $${paramIndex++}`); values.push(ticket_key.trim()); }
|
|
|
|
|
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); }
|
2026-04-28 16:36:54 +00:00
|
|
|
|
|
|
|
|
if (fields.length === 0) {
|
|
|
|
|
return res.status(400).json({ error: 'No fields to update.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
fields.push('updated_at = NOW()');
|
2026-04-28 16:36:54 +00:00
|
|
|
values.push(id);
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
|
|
|
|
|
const existing = rows[0];
|
2026-04-28 16:36:54 +00:00
|
|
|
if (!existing) {
|
|
|
|
|
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
const result = await pool.query(
|
|
|
|
|
`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = $${paramIndex}`,
|
|
|
|
|
values
|
|
|
|
|
);
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({
|
|
|
|
|
userId: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
action: 'jira_ticket_update',
|
|
|
|
|
entityType: 'jira_ticket',
|
|
|
|
|
entityId: id,
|
|
|
|
|
details: { before: existing, changes: req.body },
|
|
|
|
|
ipAddress: req.ip
|
2026-04-28 16:36:54 +00:00
|
|
|
});
|
2026-05-06 11:44:17 -06:00
|
|
|
|
|
|
|
|
res.json({ message: 'JIRA ticket updated successfully', changes: result.rowCount });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error updating JIRA ticket:', err);
|
2026-05-22 10:12:35 -06:00
|
|
|
res.status(500).json({ error: err.message || 'Internal server error.' });
|
2026-05-06 11:44:17 -06:00
|
|
|
}
|
2026-04-28 16:36:54 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-21 15:06:16 -06:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*
|
|
|
|
|
* @param {string} id - Local ticket ID (path parameter)
|
|
|
|
|
* @requires Admin or Standard_User group
|
|
|
|
|
* @returns {object} 200 - { message }
|
|
|
|
|
* @returns {object} 403 - { error: string } when user lacks permission or ticket is linked to compliance
|
|
|
|
|
* @returns {object} 404 - { error: string } when ticket not found
|
|
|
|
|
* @returns {object} 500 - { error: string } on internal error
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
2026-04-28 16:36:54 +00:00
|
|
|
const { id } = req.params;
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
|
|
|
|
|
const ticket = rows[0];
|
|
|
|
|
|
2026-04-28 16:36:54 +00:00
|
|
|
if (!ticket) {
|
|
|
|
|
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Admin bypasses all delete restrictions
|
|
|
|
|
if (req.user.group === 'Admin') {
|
|
|
|
|
return performJiraDelete();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Standard_User: compliance linkage check
|
|
|
|
|
const ticketKey = ticket.ticket_key;
|
2026-05-06 11:44:17 -06:00
|
|
|
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}%`]
|
|
|
|
|
);
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
const isLinked = (compLinks || []).some(cl => {
|
|
|
|
|
const json = cl.extra_json || '';
|
|
|
|
|
return json.includes(ticketKey);
|
|
|
|
|
});
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
if (isLinked) {
|
|
|
|
|
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
|
2026-04-28 16:36:54 +00:00
|
|
|
}
|
2026-05-06 11:44:17 -06:00
|
|
|
} catch (compErr) {
|
|
|
|
|
if (!compErr.message.includes('does not exist')) throw compErr;
|
|
|
|
|
}
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
return performJiraDelete();
|
|
|
|
|
|
|
|
|
|
async function performJiraDelete() {
|
|
|
|
|
await pool.query('DELETE FROM jira_tickets WHERE id = $1', [id]);
|
2026-04-28 16:36:54 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({
|
|
|
|
|
userId: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
action: 'jira_ticket_delete',
|
|
|
|
|
entityType: 'jira_ticket',
|
|
|
|
|
entityId: id,
|
|
|
|
|
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
|
|
|
|
|
ipAddress: req.ip
|
2026-04-28 16:36:54 +00:00
|
|
|
});
|
2026-05-06 11:44:17 -06:00
|
|
|
|
|
|
|
|
res.json({ message: 'JIRA ticket deleted successfully' });
|
2026-04-28 16:36:54 +00:00
|
|
|
}
|
2026-05-06 11:44:17 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error deleting JIRA ticket:', err);
|
2026-05-22 10:12:35 -06:00
|
|
|
res.status(500).json({ error: err.message || 'Internal server error.' });
|
2026-05-06 11:44:17 -06:00
|
|
|
}
|
2026-04-28 16:36:54 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-22 11:12:45 -06:00
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
// 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.' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-28 16:36:54 +00:00
|
|
|
return router;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = createJiraTicketsRouter;
|