From e45e40d617d55de0907050b402f9a3215ad04303 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Fri, 12 Jun 2026 15:23:29 -0600 Subject: [PATCH] Allow CVE/Vendor editing and separate completed Jira tickets 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. --- backend/routes/jiraTickets.js | 44 ++++++-- frontend/src/components/pages/JiraPage.js | 126 +++++++++++++++++++++- 2 files changed, 158 insertions(+), 12 deletions(-) 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 && (
@@ -708,7 +715,7 @@ export default function JiraPage() { - {filtered.map(t => ( + {activeFiltered.map(t => ( e.currentTarget.style.background = 'rgba(14, 165, 233, 0.05)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} @@ -767,7 +774,7 @@ export default function JiraPage() { {canWrite() && (
+ )} + + {/* Completed tickets — collapsible section */} + {completedFiltered.length > 0 && ( +
+ + + Completed ({completedFiltered.length}) + — not synced on subsequent runs + +
+ + + + + + + + + + + + + + + {completedFiltered.map(t => ( + e.currentTarget.style.background = 'rgba(14, 165, 233, 0.05)'} + onMouseLeave={e => e.currentTarget.style.background = 'transparent'} + > + + + + + + + + + + ))} + +
TicketCVEVendorSourceSummaryStatusLast SyncedActions
+
+ {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() && ( + + )} +
+
+
+
+ )} + )} {/* Lookup Modal */} @@ -851,11 +969,11 @@ export default function JiraPage() {
- setForm(f => ({ ...f, cve_id: e.target.value }))} disabled={!!editingId} /> + setForm(f => ({ ...f, cve_id: e.target.value }))} />
- setForm(f => ({ ...f, vendor: e.target.value }))} disabled={!!editingId} /> + setForm(f => ({ ...f, vendor: e.target.value }))} />