2026-02-18 15:02:25 -07:00
|
|
|
// routes/archerTickets.js
|
|
|
|
|
const express = require('express');
|
2026-04-06 16:18:07 -06:00
|
|
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
2026-02-18 15:07:07 -07:00
|
|
|
const logAudit = require('../helpers/auditLog');
|
|
|
|
|
|
|
|
|
|
// Validation helpers
|
|
|
|
|
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-02-18 15:02:25 -07:00
|
|
|
|
|
|
|
|
function createArcherTicketsRouter(db) {
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
|
|
|
|
|
// Get all Archer tickets (with optional filters)
|
|
|
|
|
router.get('/', requireAuth(db), (req, res) => {
|
|
|
|
|
const { cve_id, vendor, status } = req.query;
|
|
|
|
|
|
|
|
|
|
let query = 'SELECT * FROM archer_tickets WHERE 1=1';
|
|
|
|
|
const params = [];
|
|
|
|
|
|
|
|
|
|
if (cve_id) {
|
|
|
|
|
query += ' AND cve_id = ?';
|
|
|
|
|
params.push(cve_id);
|
|
|
|
|
}
|
|
|
|
|
if (vendor) {
|
|
|
|
|
query += ' AND vendor = ?';
|
|
|
|
|
params.push(vendor);
|
|
|
|
|
}
|
|
|
|
|
if (status) {
|
|
|
|
|
query += ' AND status = ?';
|
|
|
|
|
params.push(status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
query += ' ORDER BY created_at DESC';
|
|
|
|
|
|
|
|
|
|
db.all(query, params, (err, rows) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
console.error('Error fetching Archer tickets:', err);
|
|
|
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
|
|
|
|
res.json(rows);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Create Archer ticket
|
2026-04-06 16:18:07 -06:00
|
|
|
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
2026-02-18 15:02:25 -07:00
|
|
|
const { exc_number, archer_url, status, cve_id, vendor } = req.body;
|
|
|
|
|
|
|
|
|
|
// Validation
|
|
|
|
|
if (!exc_number || typeof exc_number !== 'string' || exc_number.trim().length === 0) {
|
|
|
|
|
return res.status(400).json({ error: 'EXC number is required.' });
|
|
|
|
|
}
|
|
|
|
|
if (!/^EXC-\d+$/.test(exc_number.trim())) {
|
|
|
|
|
return res.status(400).json({ error: 'EXC number must be in format EXC-XXXX (e.g., EXC-5754).' });
|
|
|
|
|
}
|
|
|
|
|
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 (archer_url && (typeof archer_url !== 'string' || archer_url.length > 500)) {
|
|
|
|
|
return res.status(400).json({ error: 'Archer URL must be under 500 characters.' });
|
|
|
|
|
}
|
|
|
|
|
if (status && !['Draft', 'Open', 'Under Review', 'Accepted'].includes(status)) {
|
|
|
|
|
return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const validatedStatus = status || 'Draft';
|
|
|
|
|
|
|
|
|
|
db.run(
|
2026-04-06 16:18:07 -06:00
|
|
|
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
|
|
|
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id],
|
2026-02-18 15:02:25 -07:00
|
|
|
function(err) {
|
|
|
|
|
if (err) {
|
|
|
|
|
console.error('Error creating Archer ticket:', err);
|
|
|
|
|
if (err.message.includes('UNIQUE constraint failed')) {
|
|
|
|
|
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
|
|
|
|
|
}
|
|
|
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logAudit(db, {
|
|
|
|
|
userId: req.user.id,
|
|
|
|
|
action: 'CREATE_ARCHER_TICKET',
|
2026-04-07 09:52:26 -06:00
|
|
|
entityType: 'archer_ticket',
|
|
|
|
|
entityId: String(this.lastID),
|
2026-02-18 15:02:25 -07:00
|
|
|
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
|
|
|
|
|
ipAddress: req.ip
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.status(201).json({
|
|
|
|
|
id: this.lastID,
|
|
|
|
|
message: 'Archer ticket created successfully'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Update Archer ticket
|
2026-04-06 16:18:07 -06:00
|
|
|
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
2026-02-18 15:02:25 -07:00
|
|
|
const { id } = req.params;
|
|
|
|
|
const { exc_number, archer_url, status } = req.body;
|
|
|
|
|
|
|
|
|
|
// Validation
|
|
|
|
|
if (exc_number !== undefined) {
|
|
|
|
|
if (typeof exc_number !== 'string' || exc_number.trim().length === 0) {
|
|
|
|
|
return res.status(400).json({ error: 'EXC number cannot be empty.' });
|
|
|
|
|
}
|
|
|
|
|
if (!/^EXC-\d+$/.test(exc_number.trim())) {
|
|
|
|
|
return res.status(400).json({ error: 'EXC number must be in format EXC-XXXX (e.g., EXC-5754).' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (archer_url !== undefined && archer_url !== null && (typeof archer_url !== 'string' || archer_url.length > 500)) {
|
|
|
|
|
return res.status(400).json({ error: 'Archer URL must be under 500 characters.' });
|
|
|
|
|
}
|
|
|
|
|
if (status !== undefined && !['Draft', 'Open', 'Under Review', 'Accepted'].includes(status)) {
|
|
|
|
|
return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get existing ticket
|
|
|
|
|
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, existing) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
|
|
|
|
if (!existing) {
|
|
|
|
|
return res.status(404).json({ error: 'Archer ticket not found.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updates = [];
|
|
|
|
|
const params = [];
|
|
|
|
|
|
|
|
|
|
if (exc_number !== undefined) {
|
|
|
|
|
updates.push('exc_number = ?');
|
|
|
|
|
params.push(exc_number.trim());
|
|
|
|
|
}
|
|
|
|
|
if (archer_url !== undefined) {
|
|
|
|
|
updates.push('archer_url = ?');
|
|
|
|
|
params.push(archer_url || null);
|
|
|
|
|
}
|
|
|
|
|
if (status !== undefined) {
|
|
|
|
|
updates.push('status = ?');
|
|
|
|
|
params.push(status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (updates.length === 0) {
|
|
|
|
|
return res.status(400).json({ error: 'No fields to update.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updates.push('updated_at = CURRENT_TIMESTAMP');
|
|
|
|
|
params.push(id);
|
|
|
|
|
|
|
|
|
|
db.run(
|
|
|
|
|
`UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = ?`,
|
|
|
|
|
params,
|
|
|
|
|
function(err) {
|
|
|
|
|
if (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
if (err.message.includes('UNIQUE constraint failed')) {
|
|
|
|
|
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
|
|
|
|
|
}
|
|
|
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logAudit(db, {
|
|
|
|
|
userId: req.user.id,
|
|
|
|
|
action: 'UPDATE_ARCHER_TICKET',
|
2026-04-07 09:52:26 -06:00
|
|
|
entityType: 'archer_ticket',
|
|
|
|
|
entityId: String(id),
|
2026-02-18 15:02:25 -07:00
|
|
|
details: { before: existing, changes: req.body },
|
|
|
|
|
ipAddress: req.ip
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json({ message: 'Archer ticket updated successfully', changes: this.changes });
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-06 16:18:07 -06:00
|
|
|
// Helper: perform the actual Archer ticket deletion
|
|
|
|
|
function performArcherDelete(db, req, res, id, ticket) {
|
|
|
|
|
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) {
|
|
|
|
|
if (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logAudit(db, {
|
|
|
|
|
userId: req.user.id,
|
|
|
|
|
action: 'DELETE_ARCHER_TICKET',
|
2026-04-07 09:52:26 -06:00
|
|
|
entityType: 'archer_ticket',
|
|
|
|
|
entityId: String(id),
|
2026-04-06 16:18:07 -06:00
|
|
|
details: { deleted: ticket },
|
|
|
|
|
ipAddress: req.ip
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json({ message: 'Archer ticket deleted successfully' });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 15:02:25 -07:00
|
|
|
// Delete Archer ticket
|
2026-04-06 16:18:07 -06:00
|
|
|
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
2026-02-18 15:02:25 -07:00
|
|
|
const { id } = req.params;
|
|
|
|
|
|
|
|
|
|
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
|
|
|
|
if (!ticket) {
|
|
|
|
|
return res.status(404).json({ error: 'Archer ticket not found.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 16:18:07 -06:00
|
|
|
// Admin bypasses all delete restrictions
|
|
|
|
|
if (req.user.group === 'Admin') {
|
|
|
|
|
return performArcherDelete(db, req, res, id, ticket);
|
|
|
|
|
}
|
2026-02-18 15:02:25 -07:00
|
|
|
|
2026-04-06 16:18:07 -06:00
|
|
|
// 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' });
|
|
|
|
|
}
|
2026-02-18 15:02:25 -07:00
|
|
|
|
2026-04-06 16:18:07 -06:00
|
|
|
// Standard_User: compliance linkage check
|
|
|
|
|
const excNumber = ticket.exc_number;
|
|
|
|
|
db.all(
|
|
|
|
|
`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 LIKE ?`,
|
|
|
|
|
[`%${excNumber}%`],
|
|
|
|
|
(compErr, compLinks) => {
|
|
|
|
|
// If compliance_items table doesn't exist yet, treat as no linkage
|
|
|
|
|
if (compErr && compErr.message && compErr.message.includes('no such table')) {
|
|
|
|
|
compLinks = [];
|
|
|
|
|
} else if (compErr) {
|
|
|
|
|
console.error(compErr);
|
|
|
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isLinked = (compLinks || []).some(cl => {
|
|
|
|
|
const json = cl.extra_json || '';
|
|
|
|
|
return json.includes(excNumber);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (isLinked) {
|
|
|
|
|
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return performArcherDelete(db, req, res, id, ticket);
|
|
|
|
|
}
|
|
|
|
|
);
|
2026-02-18 15:02:25 -07:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-02 09:49:32 -06:00
|
|
|
// GET /status-trend — ticket counts grouped by creation date + status
|
|
|
|
|
// Used for time-based Archer pipeline chart on the Compliance page.
|
|
|
|
|
router.get('/status-trend', requireAuth(db), (req, res) => {
|
|
|
|
|
db.all(
|
|
|
|
|
`SELECT DATE(created_at) AS date, status, COUNT(*) AS count
|
|
|
|
|
FROM archer_tickets
|
|
|
|
|
GROUP BY DATE(created_at), status
|
|
|
|
|
ORDER BY date ASC`,
|
|
|
|
|
[],
|
|
|
|
|
(err, rows) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
console.error('Error fetching Archer status trend:', err);
|
|
|
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
|
|
|
|
res.json({ statusTrend: rows });
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-18 15:02:25 -07:00
|
|
|
return router;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = createArcherTicketsRouter;
|