// routes/archerTickets.js const express = require('express'); const pool = require('../db'); const { requireAuth, requireGroup } = require('../middleware/auth'); 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; } function createArcherTicketsRouter() { const router = express.Router(); // Get all Archer tickets (with optional filters) router.get('/', requireAuth(), async (req, res) => { const { cve_id, vendor, status } = req.query; let query = 'SELECT * FROM archer_tickets WHERE 1=1'; const params = []; let paramIndex = 1; if (cve_id) { query += ` AND cve_id = $${paramIndex++}`; params.push(cve_id); } if (vendor) { query += ` AND vendor = $${paramIndex++}`; params.push(vendor); } if (status) { query += ` AND status = $${paramIndex++}`; params.push(status); } query += ' ORDER BY created_at DESC'; try { const { rows } = await pool.query(query, params); res.json(rows); } catch (err) { console.error('Error fetching Archer tickets:', err); res.status(500).json({ error: 'Internal server error.' }); } }); // Create Archer ticket router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { 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'; try { const { rows } = await pool.query( `INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, [exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id] ); logAudit({ userId: req.user.id, action: 'CREATE_ARCHER_TICKET', entityType: 'archer_ticket', entityId: String(rows[0].id), details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor }, ipAddress: req.ip }); res.status(201).json({ id: rows[0].id, message: 'Archer ticket created successfully' }); } catch (err) { console.error('Error creating Archer ticket:', err); if (err.code === '23505') { return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' }); } res.status(500).json({ error: 'Internal server error.' }); } }); // Update Archer ticket router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { 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.' }); } try { const { rows } = await pool.query('SELECT * FROM archer_tickets WHERE id = $1', [id]); const existing = rows[0]; if (!existing) { return res.status(404).json({ error: 'Archer ticket not found.' }); } const updates = []; const params = []; let paramIndex = 1; if (exc_number !== undefined) { updates.push(`exc_number = $${paramIndex++}`); params.push(exc_number.trim()); } if (archer_url !== undefined) { updates.push(`archer_url = $${paramIndex++}`); params.push(archer_url || null); } if (status !== undefined) { updates.push(`status = $${paramIndex++}`); params.push(status); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update.' }); } updates.push('updated_at = NOW()'); params.push(id); const result = await pool.query( `UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = $${paramIndex}`, params ); logAudit({ userId: req.user.id, action: 'UPDATE_ARCHER_TICKET', entityType: 'archer_ticket', entityId: String(id), details: { before: existing, changes: req.body }, ipAddress: req.ip }); res.json({ message: 'Archer ticket updated successfully', changes: result.rowCount }); } catch (err) { console.error(err); if (err.code === '23505') { return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' }); } res.status(500).json({ error: 'Internal server error.' }); } }); // Delete Archer ticket router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; try { const { rows } = await pool.query('SELECT * FROM archer_tickets WHERE id = $1', [id]); const ticket = rows[0]; if (!ticket) { return res.status(404).json({ error: 'Archer ticket not found.' }); } // Admin bypasses all delete restrictions if (req.user.group === 'Admin') { return performArcherDelete(); } // 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' }); } // Standard_User: compliance linkage check const excNumber = ticket.exc_number; try { const { rows: compLinks } = await pool.query( `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 ILIKE $1`, [`%${excNumber}%`] ); 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.' }); } } catch (compErr) { if (!compErr.message.includes('does not exist')) throw compErr; } return performArcherDelete(); async function performArcherDelete() { await pool.query('DELETE FROM archer_tickets WHERE id = $1', [id]); logAudit({ userId: req.user.id, action: 'DELETE_ARCHER_TICKET', entityType: 'archer_ticket', entityId: String(id), details: { deleted: ticket }, ipAddress: req.ip }); res.json({ message: 'Archer ticket deleted successfully' }); } } catch (err) { console.error(err); res.status(500).json({ error: 'Internal server error.' }); } }); // GET /status-trend — ticket counts grouped by creation date + status router.get('/status-trend', requireAuth(), async (req, res) => { try { const { rows } = await pool.query( `SELECT DATE(created_at) AS date, status, COUNT(*) AS count FROM archer_tickets GROUP BY DATE(created_at), status ORDER BY date ASC` ); res.json({ statusTrend: rows }); } catch (err) { console.error('Error fetching Archer status trend:', err); res.status(500).json({ error: 'Internal server error.' }); } }); return router; } module.exports = createArcherTicketsRouter;