- All 16 route files now import pool from ../db directly
- Removed db parameter from all factory functions
- All callbacks replaced with async/await pool.query()
- All ? placeholders converted to $1, $2... numbered params
- datetime('now') → NOW(), INSERT OR IGNORE → ON CONFLICT DO NOTHING
- LIKE → ILIKE for case-insensitive searches
- Error detection: err.code === '23505' for unique violations
- server.js no longer passes pool/db/requireAuth to route factories
- Only ivantiFindings.js still receives pool (pending task 8 rewrite)
150 lines
5.3 KiB
JavaScript
150 lines
5.3 KiB
JavaScript
// 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 filtering
|
|
router.get('/', async (req, res) => {
|
|
const { state } = req.query;
|
|
|
|
if (state && !VALID_STATES.includes(state)) {
|
|
return res.status(400).json({
|
|
error: 'Invalid state parameter. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED'
|
|
});
|
|
}
|
|
|
|
try {
|
|
let query = 'SELECT * FROM ivanti_finding_archives';
|
|
const params = [];
|
|
let paramIndex = 1;
|
|
|
|
if (state) {
|
|
query += ` WHERE current_state = $${paramIndex++}`;
|
|
params.push(state);
|
|
}
|
|
|
|
query += ' 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 by lifecycle state
|
|
router.get('/stats', async (req, res) => {
|
|
try {
|
|
const { rows } = await pool.query(
|
|
`SELECT current_state, COUNT(*) as count
|
|
FROM ivanti_finding_archives
|
|
GROUP BY current_state`
|
|
);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ACTIVE = total live findings count
|
|
const countResult = await pool.query(
|
|
`SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open'`
|
|
);
|
|
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
|
|
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;
|