Three changes to the Jira Tickets page: 1. CVE ID and Vendor fields are now editable in the Edit Ticket modal (previously disabled when editing). Backend PUT endpoint validates CVE format and vendor length on update. 2. Completed tickets (Closed, Done, Resolved, etc.) are shown in a separate collapsible section below the active tickets table. This keeps the active work front-and-center. 3. Sync All skips completed tickets on subsequent syncs. When a ticket first reaches a completed status via sync it gets updated normally, but on future syncs it won't be included in the batch query to Jira. Response now includes skippedCompleted count.
894 lines
39 KiB
JavaScript
894 lines
39 KiB
JavaScript
// 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');
|
|
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,}$/;
|
|
|
|
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 createJiraTicketsRouter() {
|
|
const router = express.Router();
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Jira API integration endpoints
|
|
// -----------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
router.get('/connection-test', requireAuth(), 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/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, ... } }
|
|
*/
|
|
router.get('/rate-limit', requireAuth(), requireGroup('Admin'), (req, res) => {
|
|
res.json(jiraApi.getRateLimitStatus());
|
|
});
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
router.get('/lookup/:issueKey', requireAuth(), async (req, res) => {
|
|
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.' });
|
|
}
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
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/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
|
|
*/
|
|
router.post('/create-in-jira', requireAuth(), 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;
|
|
|
|
// --- 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;
|
|
}
|
|
|
|
// --- 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;
|
|
}
|
|
|
|
// --- 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 ---
|
|
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 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)
|
|
RETURNING id`,
|
|
[normalizedCveId, normalizedVendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id, normalizedSourceContext]
|
|
);
|
|
|
|
logAudit({
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
action: 'jira_ticket_create_via_api',
|
|
entityType: 'jira_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 },
|
|
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/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
|
|
*/
|
|
router.post('/sync-all', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
|
if (!jiraApi.isConfigured) {
|
|
return res.status(503).json({ error: 'Jira API is not configured.' });
|
|
}
|
|
|
|
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 != ''"
|
|
);
|
|
|
|
// 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;
|
|
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;
|
|
|
|
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]
|
|
);
|
|
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.' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
router.post('/:id/sync', requireAuth(), 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 jira_tickets WHERE id = $1', [id]);
|
|
const ticket = rows[0];
|
|
|
|
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.' });
|
|
}
|
|
|
|
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;
|
|
|
|
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]
|
|
);
|
|
|
|
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);
|
|
return res.status(500).json({ error: err.message || 'Internal server error.' });
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Local CRUD endpoints
|
|
// -----------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
router.get('/', requireAuth(), async (req, res) => {
|
|
const { cve_id, vendor, status, source_context } = req.query;
|
|
|
|
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
|
const params = [];
|
|
let paramIndex = 1;
|
|
|
|
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 JIRA tickets:', err);
|
|
res.status(500).json({ error: err.message || 'Internal server error.' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
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)) {
|
|
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
|
|
}
|
|
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) {
|
|
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
|
|
}
|
|
normalizedVendor = vendor.trim();
|
|
}
|
|
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 && typeof status !== 'string') {
|
|
return res.status(400).json({ error: 'Status must be a string.' });
|
|
}
|
|
|
|
const ticketStatus = status || 'Open';
|
|
|
|
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`,
|
|
[normalizedCveId, normalizedVendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id]
|
|
);
|
|
|
|
logAudit({
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
action: 'jira_ticket_create',
|
|
entityType: 'jira_ticket',
|
|
entityId: rows[0].id.toString(),
|
|
details: { cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key, status: ticketStatus },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
res.status(201).json({
|
|
id: rows[0].id,
|
|
message: 'JIRA ticket created successfully'
|
|
});
|
|
} catch (err) {
|
|
console.error('Error creating JIRA ticket:', err);
|
|
res.status(500).json({ error: `Failed to save ticket: ${err.message}` });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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} [cve_id] - CVE ID (format: CVE-YYYY-NNNN+, null/empty to clear)
|
|
* @body {string} [vendor] - Vendor name (max 200 chars, null/empty to clear)
|
|
* @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
|
|
*/
|
|
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.' });
|
|
}
|
|
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 && typeof status !== 'string') {
|
|
return res.status(400).json({ error: 'Status must be a string.' });
|
|
}
|
|
|
|
const fields = [];
|
|
const values = [];
|
|
let paramIndex = 1;
|
|
|
|
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 (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); }
|
|
|
|
if (fields.length === 0) {
|
|
return res.status(400).json({ error: 'No fields to update.' });
|
|
}
|
|
|
|
fields.push('updated_at = NOW()');
|
|
values.push(id);
|
|
|
|
try {
|
|
const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [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}`,
|
|
values
|
|
);
|
|
|
|
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
|
|
});
|
|
|
|
res.json({ message: 'JIRA ticket updated successfully', changes: result.rowCount });
|
|
} catch (err) {
|
|
console.error('Error updating JIRA ticket:', err);
|
|
res.status(500).json({ error: err.message || 'Internal server error.' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
|
const { id } = req.params;
|
|
|
|
try {
|
|
const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
|
|
const ticket = rows[0];
|
|
|
|
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;
|
|
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 performJiraDelete();
|
|
|
|
async function performJiraDelete() {
|
|
await pool.query('DELETE FROM jira_tickets WHERE id = $1', [id]);
|
|
|
|
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
|
|
});
|
|
|
|
res.json({ message: 'JIRA ticket deleted successfully' });
|
|
}
|
|
} catch (err) {
|
|
console.error('Error deleting JIRA ticket:', err);
|
|
res.status(500).json({ error: err.message || 'Internal server error.' });
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Junction table endpoint — link queue items to a Jira ticket
|
|
// -----------------------------------------------------------------------
|
|
|
|
/**
|
|
* POST /api/jira-tickets/:id/queue-items
|
|
*
|
|
* Records associations between a Jira ticket and Ivanti queue items that
|
|
* contributed to it. Uses ON CONFLICT DO NOTHING to handle duplicates.
|
|
*
|
|
* @param {string} id - Local Jira ticket ID (path parameter)
|
|
* @requires Admin or Standard_User group
|
|
* @body {number[]} queue_item_ids - Non-empty array of ivanti_todo_queue IDs
|
|
* @returns {object} 201 - { message, ticket_id, linked_count }
|
|
* @returns {object} 400 - { error: string } for validation failures
|
|
* @returns {object} 404 - { error: string } when ticket not found
|
|
* @returns {object} 500 - { error: string } on internal error
|
|
*/
|
|
router.post('/:id/queue-items', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
|
const { id } = req.params;
|
|
const { queue_item_ids } = req.body;
|
|
|
|
// Validate queue_item_ids is a non-empty array of integers
|
|
if (!Array.isArray(queue_item_ids) || queue_item_ids.length === 0) {
|
|
return res.status(400).json({ error: 'queue_item_ids must be a non-empty array of integers' });
|
|
}
|
|
|
|
for (const qid of queue_item_ids) {
|
|
if (!Number.isInteger(qid)) {
|
|
return res.status(400).json({ error: 'queue_item_ids must be a non-empty array of integers' });
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Verify the jira_ticket exists
|
|
const { rows: ticketRows } = await pool.query(
|
|
'SELECT id FROM jira_tickets WHERE id = $1',
|
|
[id]
|
|
);
|
|
if (ticketRows.length === 0) {
|
|
return res.status(404).json({ error: 'Jira ticket not found' });
|
|
}
|
|
|
|
// Verify all referenced queue items exist
|
|
const { rows: existingItems } = await pool.query(
|
|
'SELECT id FROM ivanti_todo_queue WHERE id = ANY($1::int[])',
|
|
[queue_item_ids]
|
|
);
|
|
if (existingItems.length !== queue_item_ids.length) {
|
|
return res.status(400).json({ error: 'One or more queue items not found' });
|
|
}
|
|
|
|
// Insert rows with ON CONFLICT DO NOTHING
|
|
const values = queue_item_ids.map((qid, idx) => `($1, $${idx + 2})`).join(', ');
|
|
const params = [id, ...queue_item_ids];
|
|
|
|
const { rowCount } = await pool.query(
|
|
`INSERT INTO jira_ticket_queue_items (jira_ticket_id, queue_item_id)
|
|
VALUES ${values}
|
|
ON CONFLICT (jira_ticket_id, queue_item_id) DO NOTHING`,
|
|
params
|
|
);
|
|
|
|
logAudit({
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
action: 'jira_ticket_link_queue_items',
|
|
entityType: 'jira_ticket',
|
|
entityId: id,
|
|
details: { queue_item_ids, linked_count: rowCount },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
res.status(201).json({
|
|
message: 'Queue items linked to ticket',
|
|
ticket_id: parseInt(id, 10),
|
|
linked_count: rowCount
|
|
});
|
|
} catch (err) {
|
|
console.error('Error linking queue items to Jira ticket:', err);
|
|
res.status(500).json({ error: err.message || 'Internal server error.' });
|
|
}
|
|
});
|
|
|
|
return router;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|