// 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,}$/; 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; } 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.' }); } return res.status(result.status === 404 ? 404 : 502).json({ error: result.status === 404 ? 'Issue not found in Jira.' : 'Jira API error.', 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 { const { rows: tickets } = await pool.query( "SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''" ); 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: [] }; const BATCH_SIZE = 100; const batches = []; for (let i = 0; i < tickets.length; i += BATCH_SIZE) { batches.push(tickets.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 = tickets.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 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}`); } } } 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, ipAddress: req.ip }); res.json(results); } catch (err) { console.error(err); return res.status(500).json({ error: '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; 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, 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); return res.status(500).json({ error: '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: '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; if (!cve_id || !isValidCveId(cve_id)) { return res.status(400).json({ error: 'Valid CVE ID is required.' }); } if (!vendor || !isValidVendor(vendor)) { return res.status(400).json({ error: 'Valid vendor is required.' }); } 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'; 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`, [cve_id, vendor, 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, vendor, 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: 'Internal server error.' }); } }); /** * 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 */ router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; const { 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' }); } 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 = []; let paramIndex = 1; 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: '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: '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;