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.
This commit is contained in:
Jordan Ramos
2026-06-12 15:23:29 -06:00
parent 150a534943
commit e45e40d617
2 changed files with 158 additions and 12 deletions

View File

@@ -300,26 +300,40 @@ function createJiraTicketsRouter() {
} }
try { 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( const { rows: tickets } = await pool.query(
"SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''" "SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''"
); );
if (tickets.length === 0) { // Separate active vs completed tickets
return res.json({ synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] }); 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 results = { synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] };
const BATCH_SIZE = 100; const BATCH_SIZE = 100;
const batches = []; const batches = [];
for (let i = 0; i < tickets.length; i += BATCH_SIZE) { for (let i = 0; i < activeTickets.length; i += BATCH_SIZE) {
batches.push(tickets.slice(i, i + BATCH_SIZE)); batches.push(activeTickets.slice(i, i + BATCH_SIZE));
} }
for (const batch of batches) { for (const batch of batches) {
const rateStatus = jiraApi.getRateLimitStatus(); const rateStatus = jiraApi.getRateLimitStatus();
if (rateStatus.burst.remaining <= 5 || rateStatus.daily.remaining <= 10) { 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.skipped += remaining;
results.errors.push('Rate limit approaching — stopped sync early to preserve budget.'); results.errors.push('Rate limit approaching — stopped sync early to preserve budget.');
break; break;
@@ -377,11 +391,11 @@ function createJiraTicketsRouter() {
action: 'jira_sync_all', action: 'jira_sync_all',
entityType: 'jira_integration', entityType: 'jira_integration',
entityId: null, entityId: null,
details: results, details: { ...results, skippedCompleted },
ipAddress: req.ip ipAddress: req.ip
}); });
res.json(results); res.json({ ...results, skippedCompleted });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
return res.status(500).json({ error: err.message || 'Internal server error.' }); 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) * @param {string} id - Local ticket ID (path parameter)
* @requires Admin or Standard_User group * @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} [ticket_key] - Jira ticket key (max 50 chars)
* @body {string} [url] - Jira ticket URL (max 500 chars, null to clear) * @body {string} [url] - Jira ticket URL (max 500 chars, null to clear)
* @body {string} [summary] - Summary (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) => { router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { id } = req.params; 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) // source_context is immutable after creation (Requirement 3.6)
if ('source_context' in req.body) { if ('source_context' in req.body) {
return res.status(400).json({ error: 'source_context is immutable after creation' }); 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)) { 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.' }); return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
} }
@@ -637,6 +663,8 @@ function createJiraTicketsRouter() {
const values = []; const values = [];
let paramIndex = 1; 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 (ticket_key !== undefined) { fields.push(`ticket_key = $${paramIndex++}`); values.push(ticket_key.trim()); }
if (url !== undefined) { fields.push(`url = $${paramIndex++}`); values.push(url); } if (url !== undefined) { fields.push(`url = $${paramIndex++}`); values.push(url); }
if (summary !== undefined) { fields.push(`summary = $${paramIndex++}`); values.push(summary); } if (summary !== undefined) { fields.push(`summary = $${paramIndex++}`); values.push(summary); }

View File

@@ -549,6 +549,10 @@ export default function JiraPage() {
closed: tickets.filter(t => isClosedStatus(t.status)).length, 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 // 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.'} {tickets.length === 0 ? 'No Jira tickets yet. Add one manually or create from Jira.' : 'No tickets match your filters.'}
</div> </div>
) : ( ) : (
<>
{/* Active tickets */}
{activeFiltered.length > 0 && (
<div style={{ ...STYLES.card, padding: '0', overflow: 'auto' }}> <div style={{ ...STYLES.card, padding: '0', overflow: 'auto' }}>
<table style={STYLES.table}> <table style={STYLES.table}>
<thead> <thead>
@@ -708,7 +715,7 @@ export default function JiraPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filtered.map(t => ( {activeFiltered.map(t => (
<tr key={t.id} style={{ transition: 'background 0.15s' }} <tr key={t.id} style={{ transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(14, 165, 233, 0.05)'} onMouseEnter={e => e.currentTarget.style.background = 'rgba(14, 165, 233, 0.05)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
@@ -767,7 +774,7 @@ export default function JiraPage() {
{canWrite() && ( {canWrite() && (
<button style={{ ...STYLES.btn, padding: '0.25rem 0.5rem', fontSize: '0.7rem' }} onClick={() => { <button style={{ ...STYLES.btn, padding: '0.25rem 0.5rem', fontSize: '0.7rem' }} onClick={() => {
setEditingId(t.id); setEditingId(t.id);
setForm({ cve_id: t.cve_id, vendor: t.vendor, ticket_key: t.ticket_key, url: t.url || '', summary: t.summary || '', status: t.status }); setForm({ cve_id: t.cve_id || '', vendor: t.vendor || '', ticket_key: t.ticket_key, url: t.url || '', summary: t.summary || '', status: t.status });
setFormError(null); setFormError(null);
setShowForm(true); setShowForm(true);
}} title="Edit"> }} title="Edit">
@@ -786,6 +793,117 @@ export default function JiraPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
)}
{/* Completed tickets — collapsible section */}
{completedFiltered.length > 0 && (
<details style={{ marginTop: '1.5rem' }}>
<summary style={{
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: 600,
color: '#64748B',
padding: '0.5rem 0',
userSelect: 'none',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}}>
<CheckCircle size={14} style={{ color: '#10B981' }} />
Completed ({completedFiltered.length})
<span style={{ fontSize: '0.7rem', fontWeight: 400, color: '#475569' }}> not synced on subsequent runs</span>
</summary>
<div style={{ ...STYLES.card, padding: '0', overflow: 'auto', marginTop: '0.5rem', opacity: 0.75 }}>
<table style={STYLES.table}>
<thead>
<tr>
<th style={STYLES.th}>Ticket</th>
<th style={STYLES.th}>CVE</th>
<th style={STYLES.th}>Vendor</th>
<th style={STYLES.th}>Source</th>
<th style={STYLES.th}>Summary</th>
<th style={STYLES.th}>Status</th>
<th style={STYLES.th}>Last Synced</th>
<th style={STYLES.th}>Actions</th>
</tr>
</thead>
<tbody>
{completedFiltered.map(t => (
<tr key={t.id} style={{ transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(14, 165, 233, 0.05)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<td style={STYLES.td}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<span style={{ fontFamily: 'monospace', fontWeight: 600, color: '#7DD3FC' }}>{t.ticket_key}</span>
{t.url && (
<a href={t.url} target="_blank" rel="noopener noreferrer" style={{ color: '#94A3B8' }} title="Open in Jira">
<ExternalLink size={12} />
</a>
)}
</div>
</td>
<td style={{ ...STYLES.td, fontFamily: 'monospace', fontSize: '0.8rem' }}>{t.cve_id}</td>
<td style={STYLES.td}>{t.vendor}</td>
<td style={STYLES.td}>
{(() => {
const badge = getSourceBadge(t.source_context);
return (
<span style={{
display: 'inline-flex',
alignItems: 'center',
padding: '0.15rem 0.5rem',
borderRadius: '9999px',
fontSize: '0.65rem',
fontWeight: 600,
letterSpacing: '0.02em',
background: `${badge.color}22`,
color: badge.color,
border: `1px solid ${badge.color}44`,
whiteSpace: 'nowrap',
}}>
{badge.label}
</span>
);
})()}
</td>
<td style={{ ...STYLES.td, maxWidth: '250px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.summary || '-'}</td>
<td style={STYLES.td}>
<span style={STYLES.badge(getStatusColor(t.status))}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: getStatusColor(t.status) }} />
{t.status}
</span>
</td>
<td style={{ ...STYLES.td, fontSize: '0.75rem', color: '#94A3B8' }}>
{t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never'}
</td>
<td style={STYLES.td}>
<div style={{ display: 'flex', gap: '0.3rem' }}>
{canWrite() && (
<button style={{ ...STYLES.btn, padding: '0.25rem 0.5rem', fontSize: '0.7rem' }} onClick={() => {
setEditingId(t.id);
setForm({ cve_id: t.cve_id || '', vendor: t.vendor || '', ticket_key: t.ticket_key, url: t.url || '', summary: t.summary || '', status: t.status });
setFormError(null);
setShowForm(true);
}} title="Edit">
<Edit3 size={12} />
</button>
)}
{canWrite() && (
<button style={{ ...STYLES.btn, ...STYLES.btnDanger, padding: '0.25rem 0.5rem', fontSize: '0.7rem' }} onClick={() => deleteTicket(t.id)} title="Delete">
<Trash2 size={12} />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</>
)} )}
{/* Lookup Modal */} {/* Lookup Modal */}
@@ -851,11 +969,11 @@ export default function JiraPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div> <div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>CVE ID</label> <label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>CVE ID</label>
<input style={STYLES.input} placeholder="CVE-2024-1234" value={form.cve_id} onChange={e => setForm(f => ({ ...f, cve_id: e.target.value }))} disabled={!!editingId} /> <input style={STYLES.input} placeholder="CVE-2024-1234" value={form.cve_id} onChange={e => setForm(f => ({ ...f, cve_id: e.target.value }))} />
</div> </div>
<div> <div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor</label> <label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor</label>
<input style={STYLES.input} placeholder="Vendor name" value={form.vendor} onChange={e => setForm(f => ({ ...f, vendor: e.target.value }))} disabled={!!editingId} /> <input style={STYLES.input} placeholder="Vendor name" value={form.vendor} onChange={e => setForm(f => ({ ...f, vendor: e.target.value }))} />
</div> </div>
<div> <div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Ticket Key</label> <label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Ticket Key</label>