diff --git a/backend/routes/jiraTickets.js b/backend/routes/jiraTickets.js index ffec557..e5b3c63 100644 --- a/backend/routes/jiraTickets.js +++ b/backend/routes/jiraTickets.js @@ -300,26 +300,40 @@ function createJiraTicketsRouter() { } try { + // Only sync tickets that are NOT in a completed/closed state. + // Completed tickets are pulled on the sync where they first become completed, + // but on subsequent syncs they are skipped to avoid unnecessary API calls. const { rows: tickets } = await pool.query( "SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''" ); - if (tickets.length === 0) { - return res.json({ synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] }); + // 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 < tickets.length; i += BATCH_SIZE) { - batches.push(tickets.slice(i, i + BATCH_SIZE)); + 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 = tickets.length - results.synced - results.failed - results.unchanged; + 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; @@ -377,11 +391,11 @@ function createJiraTicketsRouter() { action: 'jira_sync_all', entityType: 'jira_integration', entityId: null, - details: results, + details: { ...results, skippedCompleted }, ipAddress: req.ip }); - res.json(results); + res.json({ ...results, skippedCompleted }); } catch (err) { console.error(err); return res.status(500).json({ error: err.message || 'Internal server error.' }); @@ -602,6 +616,8 @@ function createJiraTicketsRouter() { * * @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) @@ -613,13 +629,23 @@ function createJiraTicketsRouter() { */ router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; - const { ticket_key, url, summary, status } = req.body; + 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.' }); } @@ -637,6 +663,8 @@ function createJiraTicketsRouter() { 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); } diff --git a/frontend/src/components/pages/JiraPage.js b/frontend/src/components/pages/JiraPage.js index 8f18948..0496d96 100644 --- a/frontend/src/components/pages/JiraPage.js +++ b/frontend/src/components/pages/JiraPage.js @@ -549,6 +549,10 @@ export default function JiraPage() { closed: tickets.filter(t => isClosedStatus(t.status)).length, }; + // Split filtered into active and completed for separate display + const activeFiltered = filtered.filter(t => !isClosedStatus(t.status)); + const completedFiltered = filtered.filter(t => isClosedStatus(t.status)); + // --------------------------------------------------------------------------- // Render @@ -693,6 +697,9 @@ export default function JiraPage() { {tickets.length === 0 ? 'No Jira tickets yet. Add one manually or create from Jira.' : 'No tickets match your filters.'} ) : ( + <> + {/* Active tickets */} + {activeFiltered.length > 0 && (
| Ticket | +CVE | +Vendor | +Source | +Summary | +Status | +Last Synced | +Actions | +
|---|---|---|---|---|---|---|---|
|
+
+ {t.ticket_key}
+ {t.url && (
+
+
+ |
+ {t.cve_id} | +{t.vendor} | ++ {(() => { + const badge = getSourceBadge(t.source_context); + return ( + + {badge.label} + + ); + })()} + | +{t.summary || '-'} | ++ + + {t.status} + + | ++ {t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never'} + | +
+
+ {canWrite() && (
+
+ )}
+ {canWrite() && (
+
+ )}
+
+ |
+