feat: implement finding archive tracking system

- Add migration script for ivanti_finding_archives and ivanti_archive_transitions tables
- Add archive detection logic (detectArchiveChanges, detectClosedFindings) in sync pipeline
- Add archive API router with list, stats, and history endpoints at /api/ivanti/archive
- Add ArchiveSummaryBar UI component with four state cards (ACTIVE, ARCHIVED, RETURNED, CLOSED)
- Integrate ArchiveSummaryBar into Ivanti findings page in App.js
- Register archive router in server.js
This commit is contained in:
jramos
2026-04-03 15:20:04 -06:00
parent 2b4ec5d8e2
commit 9bd5a52661
9 changed files with 699 additions and 28 deletions

View File

@@ -0,0 +1,122 @@
// Ivanti Archive Routes — list, stats, and transition history for archived findings
const express = require('express');
const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED'];
function createIvantiArchiveRouter(db, requireAuth) {
const router = express.Router();
// All routes require authentication
router.use(requireAuth(db));
// GET / — List archive records with optional ?state= filter
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 = [];
if (state) {
query += ' WHERE current_state = ?';
params.push(state);
}
query += ' ORDER BY last_transition_at DESC';
const archives = await new Promise((resolve, reject) => {
db.all(query, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
res.json({ archives, total: archives.length });
} catch (err) {
console.error('Archive list error:', err);
res.status(500).json({ error: 'Failed to fetch archive records' });
}
});
// GET /stats — Summary counts by state
router.get('/stats', async (req, res) => {
try {
const rows = await new Promise((resolve, reject) => {
db.all(
`SELECT current_state, COUNT(*) as count
FROM ivanti_finding_archives
GROUP BY current_state`,
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 };
let total = 0;
for (const row of rows) {
if (stats.hasOwnProperty(row.current_state)) {
stats[row.current_state] = row.count;
}
total += row.count;
}
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 finding
router.get('/:findingId/history', async (req, res) => {
const { findingId } = req.params;
try {
const archive = await new Promise((resolve, reject) => {
db.get(
'SELECT id FROM ivanti_finding_archives WHERE finding_id = ?',
[findingId],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (!archive) {
return res.json({ finding_id: findingId, transitions: [] });
}
const transitions = await new Promise((resolve, reject) => {
db.all(
`SELECT * FROM ivanti_archive_transitions
WHERE archive_id = ?
ORDER BY transitioned_at DESC`,
[archive.id],
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
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;