Add issue type dropdown and Save to Dashboard from lookup

- Replace issue type text input with dropdown of STEAM project types (Story default)
- Add Save to Dashboard button on lookup results to link existing Jira tickets locally
- Make cve_id and vendor optional on local POST /api/jira-tickets endpoint
- Fix: use normalized values in local ticket INSERT query
This commit is contained in:
Jordan Ramos
2026-05-21 16:01:31 -06:00
parent dff1fa3cc9
commit 758a300f67
2 changed files with 72 additions and 8 deletions

View File

@@ -522,11 +522,21 @@ function createJiraTicketsRouter() {
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.' });
// 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;
}
if (!vendor || !isValidVendor(vendor)) {
return res.status(400).json({ error: 'Valid vendor is required.' });
// 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).' });
@@ -548,7 +558,7 @@ function createJiraTicketsRouter() {
`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]
[normalizedCveId, normalizedVendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id]
);
logAudit({
@@ -557,7 +567,7 @@ function createJiraTicketsRouter() {
action: 'jira_ticket_create',
entityType: 'jira_ticket',
entityId: rows[0].id.toString(),
details: { cve_id, vendor, ticket_key, status: ticketStatus },
details: { cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key, status: ticketStatus },
ipAddress: req.ip
});

View File

@@ -182,6 +182,9 @@ export default function JiraPage() {
const [lookupResult, setLookupResult] = useState(null);
const [lookupLoading, setLookupLoading] = useState(false);
const [lookupError, setLookupError] = useState(null);
const [linkingSaving, setLinkingSaving] = useState(false);
const [linkingError, setLinkingError] = useState(null);
const [linkingSuccess, setLinkingSuccess] = useState(null);
// Add/Edit modal
const [showForm, setShowForm] = useState(false);
@@ -287,6 +290,37 @@ export default function JiraPage() {
}
};
// ---------------------------------------------------------------------------
// Link existing Jira ticket — save to local DB without recreating in Jira
// ---------------------------------------------------------------------------
const linkExistingTicket = async (issue) => {
setLinkingError(null);
setLinkingSuccess(null);
setLinkingSaving(true);
try {
const jiraUrl = `https://jira.charter.com/browse/${issue.key}`;
const res = await fetch(`${API_BASE}/jira-tickets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
ticket_key: issue.key,
url: jiraUrl,
summary: issue.summary || '',
status: issue.status === 'Open' || issue.status === 'In Progress' || issue.status === 'Closed' ? issue.status : 'Open',
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
setLinkingSuccess(`${issue.key} saved to dashboard.`);
fetchTickets();
} catch (err) {
setLinkingError(err.message);
} finally {
setLinkingSaving(false);
}
};
// ---------------------------------------------------------------------------
// CRUD — save (create or update)
@@ -715,6 +749,18 @@ export default function JiraPage() {
<div><strong>Priority:</strong> {lookupResult.priority}</div>
<div><strong>Assignee:</strong> {lookupResult.assignee || 'Unassigned'}</div>
<div><strong>Updated:</strong> {lookupResult.updated ? new Date(lookupResult.updated).toLocaleString() : '-'}</div>
{canWrite() && (
<button
style={{ ...STYLES.btn, ...STYLES.btnSuccess, marginTop: '0.75rem' }}
onClick={() => linkExistingTicket(lookupResult)}
disabled={linkingSaving}
>
{linkingSaving ? <Loader size={14} className="animate-spin" /> : <Plus size={14} />}
Save to Dashboard
</button>
)}
{linkingError && <div style={{ color: '#FCA5A5', fontSize: '0.75rem', marginTop: '0.5rem' }}>{linkingError}</div>}
{linkingSuccess && <div style={{ color: '#6EE7B7', fontSize: '0.75rem', marginTop: '0.5rem' }}>{linkingSuccess}</div>}
</div>
)}
</div>
@@ -828,8 +874,16 @@ export default function JiraPage() {
<input style={STYLES.input} placeholder="Uses .env default" value={createJiraForm.project_key} onChange={e => setCreateJiraForm(f => ({ ...f, project_key: e.target.value.toUpperCase() }))} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type (optional)</label>
<input style={STYLES.input} placeholder="Task" value={createJiraForm.issue_type} onChange={e => setCreateJiraForm(f => ({ ...f, issue_type: e.target.value }))} />
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type</label>
<select style={{ ...STYLES.input, cursor: 'pointer' }} value={createJiraForm.issue_type} onChange={e => setCreateJiraForm(f => ({ ...f, issue_type: e.target.value }))}>
<option value="">Story (default)</option>
<option value="Story">Story</option>
<option value="Epic">Epic</option>
<option value="Program">Program</option>
<option value="Project">Project</option>
<option value="Reservation">Reservation</option>
<option value="Automation Maintenance">Automation Maintenance</option>
</select>
</div>
</div>
<button style={{ ...STYLES.btn, ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }} onClick={createInJira} disabled={createJiraSaving}>