// Ivanti Archive Routes — list, stats, and transition history for archived findings const express = require('express'); const pool = require('../db'); const { requireAuth } = require('../middleware/auth'); const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED']; /** * Find the most severe active finding related to an archived finding. */ function findRelatedActive(archive, activeFindings) { const archiveTitle = (archive.finding_title || '').toLowerCase(); const matches = activeFindings.filter(f => { if (f.hostName !== archive.host_name) return false; if (f.id === archive.finding_id) return false; const activeTitle = (f.title || '').toLowerCase(); if (!archiveTitle.includes(activeTitle) && !activeTitle.includes(archiveTitle)) return false; return true; }); if (matches.length === 0) return null; const best = matches.reduce((a, b) => (b.severity > a.severity ? b : a)); return { id: best.id, title: best.title, severity: best.severity }; } function createIvantiArchiveRouter() { const router = express.Router(); // All routes require authentication router.use(requireAuth()); /** * GET / * List archive records with optional state and teams filtering. * * @query {string} [state] - Filter by lifecycle state. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED. * When state=ACTIVE, returns live open findings from ivanti_findings instead of archives. * When state=CLOSED, includes both CLOSED and CLOSED_GONE records. * @query {string} [teams] - Comma-separated BU team names (e.g. 'STEAM,ACCESS-ENG'). * Filters results to findings whose bu_ownership contains one of the specified teams. * * @response {object} 200 * { archives: Array<{ id, finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at, created_at, related_active: object|null }>, total: number } * @response {object} 400 - Invalid state parameter * { error: string } * @response {object} 500 - Database error * { error: string } */ router.get('/', async (req, res) => { const { state, teams } = req.query; if (state && !VALID_STATES.includes(state)) { return res.status(400).json({ error: 'Invalid state parameter. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED' }); } // Parse teams filter into ILIKE patterns const teamPatterns = teams ? teams.split(',').map(t => `%${t.trim()}%`).filter(p => p !== '%%') : []; try { // ACTIVE state comes from ivanti_findings (live open findings), not archives if (state === 'ACTIVE') { let activeQuery = `SELECT id, id AS finding_id, title AS finding_title, host_name, ip_address, 'ACTIVE' AS current_state, severity AS last_severity, synced_at AS first_archived_at, synced_at AS last_transition_at, synced_at AS created_at FROM ivanti_findings WHERE state = 'open'`; const activeParams = []; let activeIdx = 1; if (teamPatterns.length > 0) { activeQuery += ` AND bu_ownership ILIKE ANY($${activeIdx++}::text[])`; activeParams.push(teamPatterns); } activeQuery += ` ORDER BY severity DESC NULLS LAST LIMIT 200`; const { rows: activeRows } = await pool.query(activeQuery, activeParams); const archives = activeRows.map(r => ({ ...r, related_active: null })); return res.json({ archives, total: archives.length }); } // For non-ACTIVE states, query archives with optional BU join let query, params = [], paramIndex = 1; if (teamPatterns.length > 0) { // JOIN with ivanti_findings to filter by bu_ownership query = `SELECT a.* FROM ivanti_finding_archives a INNER JOIN ivanti_findings f ON a.finding_id = f.id WHERE f.bu_ownership ILIKE ANY($${paramIndex++}::text[])`; params.push(teamPatterns); if (state) { if (state === 'CLOSED') { query += ` AND a.current_state IN ($${paramIndex++}, $${paramIndex++})`; params.push('CLOSED', 'CLOSED_GONE'); } else { query += ` AND a.current_state = $${paramIndex++}`; params.push(state); } } } else { query = 'SELECT * FROM ivanti_finding_archives'; if (state) { if (state === 'CLOSED') { query += ` WHERE current_state IN ($${paramIndex++}, $${paramIndex++})`; params.push('CLOSED', 'CLOSED_GONE'); } else { query += ` WHERE current_state = $${paramIndex++}`; params.push(state); } } } query += teamPatterns.length > 0 ? ' ORDER BY a.last_transition_at DESC' : ' ORDER BY last_transition_at DESC'; const { rows: archives } = await pool.query(query, params); // Fetch active findings for related-finding enrichment // In the new schema, active findings are in ivanti_findings table let activeFindings = []; try { const { rows: findingsRows } = await pool.query( `SELECT id, title, host_name AS "hostName", severity FROM ivanti_findings WHERE state = 'open'` ); activeFindings = findingsRows; } catch (cacheErr) { console.warn('Failed to load findings for related-active matching:', cacheErr); } // Enrich each archive record with related active finding info const enrichedArchives = archives.map(archive => ({ ...archive, related_active: findRelatedActive(archive, activeFindings) })); res.json({ archives: enrichedArchives, total: enrichedArchives.length }); } catch (err) { console.error('Archive list error:', err); res.status(500).json({ error: 'Failed to fetch archive records' }); } }); /** * GET /stats * Summary counts of archive records grouped by lifecycle state. * * @query {string} [teams] - Comma-separated BU team names (e.g. 'STEAM,ACCESS-ENG'). * Filters counts to findings whose bu_ownership contains one of the specified teams. * * @response {object} 200 * { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number } * @response {object} 500 - Database error * { error: string } */ router.get('/stats', async (req, res) => { try { const { teams } = req.query; const teamPatterns = teams ? teams.split(',').map(t => `%${t.trim()}%`).filter(p => p !== '%%') : []; let archiveQuery, archiveParams = []; if (teamPatterns.length > 0) { archiveQuery = `SELECT a.current_state, COUNT(*) as count FROM ivanti_finding_archives a INNER JOIN ivanti_findings f ON a.finding_id = f.id WHERE f.bu_ownership ILIKE ANY($1::text[]) GROUP BY a.current_state`; archiveParams = [teamPatterns]; } else { archiveQuery = `SELECT current_state, COUNT(*) as count FROM ivanti_finding_archives GROUP BY current_state`; } const { rows } = await pool.query(archiveQuery, archiveParams); const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 }; for (const row of rows) { if (stats.hasOwnProperty(row.current_state)) { stats[row.current_state] += parseInt(row.count); } else if (row.current_state === 'CLOSED_GONE') { stats.CLOSED += parseInt(row.count); } } // ACTIVE = total live findings count (scoped by teams if provided) let activeQuery, activeParams = []; if (teamPatterns.length > 0) { activeQuery = `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open' AND bu_ownership ILIKE ANY($1::text[])`; activeParams = [teamPatterns]; } else { activeQuery = `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open'`; } const countResult = await pool.query(activeQuery, activeParams); stats.ACTIVE = parseInt(countResult.rows[0].total) || 0; const total = stats.ACTIVE + stats.ARCHIVED + stats.RETURNED + stats.CLOSED; res.json({ ...stats, total }); } catch (err) { console.error('Archive stats error:', err); res.status(500).json({ error: 'Failed to fetch archive stats' }); } }); /** * GET /:findingId/history * Transition history for a specific archived finding. * * @param {string} findingId - The finding ID to look up in the archives. * * @response {object} 200 * { finding_id: string, transitions: Array<{ id, archive_id, from_state, to_state, transitioned_at, reason }> } * @response {object} 500 - Database error * { error: string } */ router.get('/:findingId/history', async (req, res) => { const { findingId } = req.params; try { const { rows: archiveRows } = await pool.query( 'SELECT id FROM ivanti_finding_archives WHERE finding_id = $1', [findingId] ); const archive = archiveRows[0]; if (!archive) { return res.json({ finding_id: findingId, transitions: [] }); } const { rows: transitions } = await pool.query( `SELECT * FROM ivanti_archive_transitions WHERE archive_id = $1 ORDER BY transitioned_at DESC`, [archive.id] ); res.json({ finding_id: findingId, transitions }); } catch (err) { console.error('Archive history error:', err); res.status(500).json({ error: 'Failed to fetch transition history' }); } }); return router; } module.exports = createIvantiArchiveRouter;