feat(postgres): migrate all route files from SQLite to pg pool

- 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)
This commit is contained in:
Jordan Ramos
2026-05-06 11:44:17 -06:00
parent 845d843e71
commit 33927b150b
18 changed files with 2164 additions and 4432 deletions

View File

@@ -1,19 +1,12 @@
// 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.
*
* A match requires:
* - Exact hostname match (case-sensitive)
* - The archive title is a case-insensitive substring of the active title, or vice versa
* - The active finding ID differs from the archive's finding_id
*
* @param {Object} archive - Archive record from ivanti_finding_archives
* @param {Array} activeFindings - Parsed entries from ivanti_findings_cache
* @returns {{ id: string, title: string, severity: number } | null}
*/
function findRelatedActive(archive, activeFindings) {
const archiveTitle = (archive.finding_title || '').toLowerCase();
@@ -34,21 +27,13 @@ function findRelatedActive(archive, activeFindings) {
return { id: best.id, title: best.title, severity: best.severity };
}
function createIvantiArchiveRouter(db, requireAuth) {
function createIvantiArchiveRouter() {
const router = express.Router();
// All routes require authentication
router.use(requireAuth(db));
router.use(requireAuth());
/**
* 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
*/
// GET / — List archive records with optional state filtering
router.get('/', async (req, res) => {
const { state } = req.query;
@@ -61,43 +46,27 @@ function createIvantiArchiveRouter(db, requireAuth) {
try {
let query = 'SELECT * FROM ivanti_finding_archives';
const params = [];
let paramIndex = 1;
if (state) {
query += ' WHERE current_state = ?';
query += ` WHERE current_state = $${paramIndex++}`;
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 || []);
});
});
const { rows: archives } = await pool.query(query, params);
// Fetch and parse active findings cache for related-finding enrichment
// Fetch active findings for related-finding enrichment
// In the new schema, active findings are in ivanti_findings table
let activeFindings = [];
try {
const cacheRow = await new Promise((resolve, reject) => {
db.get(
'SELECT findings_json FROM ivanti_findings_cache WHERE id = 1',
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (cacheRow && cacheRow.findings_json) {
activeFindings = JSON.parse(cacheRow.findings_json);
}
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 cache for related-active matching:', cacheErr);
}
if (!Array.isArray(activeFindings)) {
activeFindings = [];
console.warn('Failed to load findings for related-active matching:', cacheErr);
}
// Enrich each archive record with related active finding info
@@ -113,52 +82,28 @@ function createIvantiArchiveRouter(db, requireAuth) {
}
});
/**
* 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
*/
// GET /stats — Summary counts by lifecycle state
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 { 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] = row.count;
stats[row.current_state] = parseInt(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;
// 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;
@@ -169,46 +114,27 @@ function createIvantiArchiveRouter(db, requireAuth) {
}
});
/**
* 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
*/
// GET /:findingId/history — Transition history for a specific archived 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);
}
);
});
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 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 || []);
}
);
});
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) {