1. ACTIVE state never populated — stats endpoint now computes ACTIVE from live findings cache count instead of querying archive table 2. CHECK constraint mismatch — migration now uses 3-state constraint (ARCHIVED, RETURNED, CLOSED) matching runtime initArchiveTables() 3. Archive filter click non-functional — handleArchiveStateClick now fetches and renders filtered archive list below summary bar 4. Hook glob pattern mismatch — changed **/migrate*.js to **/migrations/*.js so hook fires for actual migration filenames 5. Stale stats after sync — ArchiveSummaryBar polls every 60s and refreshes immediately after workflow sync via refreshKey prop
163 lines
5.9 KiB
JavaScript
163 lines
5.9 KiB
JavaScript
// 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 filtering.
|
|
*
|
|
* @query {string} [state] - Filter by lifecycle state (ACTIVE, ARCHIVED, RETURNED, CLOSED)
|
|
* @returns {Object} 200 - { archives: Array<ArchiveRecord>, total: number }
|
|
* @returns {Object} 400 - { error: string } when state param is invalid
|
|
* @returns {Object} 500 - { error: string } on database failure
|
|
*/
|
|
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 of archive records by lifecycle state.
|
|
* ACTIVE is implicit: live findings in the cache that have no ARCHIVED/RETURNED archive record.
|
|
*
|
|
* @returns {Object} 200 - { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number }
|
|
* @returns {Object} 500 - { error: string } on database failure
|
|
*/
|
|
router.get('/stats', async (req, res) => {
|
|
try {
|
|
// Count archive records by state
|
|
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 };
|
|
|
|
for (const row of rows) {
|
|
if (stats.hasOwnProperty(row.current_state)) {
|
|
stats[row.current_state] = row.count;
|
|
}
|
|
}
|
|
|
|
// Compute ACTIVE: total live findings minus those with ARCHIVED or RETURNED records
|
|
const cacheRow = await new Promise((resolve, reject) => {
|
|
db.get(
|
|
'SELECT total FROM ivanti_findings_cache WHERE id = 1',
|
|
(err, row) => {
|
|
if (err) reject(err);
|
|
else resolve(row);
|
|
}
|
|
);
|
|
});
|
|
|
|
const liveFindingsCount = (cacheRow && cacheRow.total) || 0;
|
|
// Findings that are ARCHIVED or RETURNED are "missing" from the live set,
|
|
// so ACTIVE = live count (all findings currently present in sync results)
|
|
stats.ACTIVE = liveFindingsCount;
|
|
|
|
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, ordered by most recent first.
|
|
* Returns an empty transitions array if the finding has no archive record.
|
|
*
|
|
* @param {string} findingId - Ivanti finding identifier (route param)
|
|
* @returns {Object} 200 - { finding_id: string, transitions: Array<TransitionRecord> }
|
|
* @returns {Object} 500 - { error: string } on database failure
|
|
*/
|
|
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;
|