// VCL Multi-Vertical Routes — Cross-organizational compliance reporting // Handles multi-file per-vertical xlsx upload, scoped resolution, and executive reporting. const express = require('express'); const path = require('path'); const fs = require('fs'); const { spawn } = require('child_process'); const pool = require('../db'); const { requireAuth, requireGroup } = require('../middleware/auth'); const { parseVerticalFilename, computeVerticalBurndown, isValidDateString, categorizeNonCompliant } = require('../helpers/vclHelpers'); const logAudit = require('../helpers/auditLog'); const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py'); const PYTHON_BIN = process.env.PYTHON_BIN || 'python3'; const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp'); const VCL_TARGET_PCT = parseInt(process.env.VCL_TARGET_PCT, 10) || 95; // --------------------------------------------------------------------------- // Run Python parser, return parsed object // --------------------------------------------------------------------------- function parseXlsx(filePath) { return new Promise((resolve, reject) => { const py = spawn(PYTHON_BIN, [PARSER_SCRIPT, filePath]); let out = ''; let err = ''; py.stdout.on('data', d => { out += d; }); py.stderr.on('data', d => { err += d; }); py.on('close', code => { if (code !== 0) return reject(new Error(err || `Parser exited with code ${code}`)); try { resolve(JSON.parse(out)); } catch (e) { reject(new Error('Parser returned invalid JSON')); } }); py.on('error', reject); }); } // --------------------------------------------------------------------------- // Compute scoped diff: only considers items within the same vertical // --------------------------------------------------------------------------- async function computeScopedDiff(incomingItems, vertical) { const { rows: activeRows } = await pool.query( `SELECT hostname, metric_id FROM compliance_items WHERE status = 'active' AND vertical = $1`, [vertical] ); const activeKeys = new Set(activeRows.map(r => `${r.hostname}|||${r.metric_id}`)); const newKeys = new Set(incomingItems.map(i => `${i.hostname}|||${i.metric_id}`)); let newCount = 0, recurringCount = 0, resolvedCount = 0; for (const k of newKeys) { if (activeKeys.has(k)) recurringCount++; else newCount++; } for (const k of activeKeys) { if (!newKeys.has(k)) resolvedCount++; } return { newCount, recurringCount, resolvedCount }; } // --------------------------------------------------------------------------- // Persist a single vertical's upload with scoped resolution // --------------------------------------------------------------------------- async function persistMultiVerticalUpload({ items, summary, reportDate, filename, vertical, userId }, client) { // Get active items for THIS vertical only const { rows: activeRows } = await client.query( `SELECT id, hostname, metric_id, seen_count, first_seen_upload_id FROM compliance_items WHERE status = 'active' AND vertical = $1`, [vertical] ); const activeMap = {}; activeRows.forEach(r => { activeMap[`${r.hostname}|||${r.metric_id}`] = r; }); const newKeys = new Set(items.map(i => `${i.hostname}|||${i.metric_id}`)); // 1. Insert the upload record const uploadResult = await client.query( `INSERT INTO compliance_uploads (filename, report_date, uploaded_by, uploaded_at, vertical, summary_json) VALUES ($1, $2, $3, NOW(), $4, $5) RETURNING id`, [filename, reportDate || null, userId || null, vertical, JSON.stringify(summary)] ); const uploadId = uploadResult.rows[0].id; let newCount = 0, recurringCount = 0, resolvedCount = 0; // 2. Upsert each incoming non-compliant item for (const item of items) { const key = `${item.hostname}|||${item.metric_id}`; const existing = activeMap[key]; const extraStr = JSON.stringify(item.extra_json || {}); if (existing) { await client.query( `UPDATE compliance_items SET upload_id = $1, seen_count = $2, ip_address = $3, device_type = $4, extra_json = $5, metric_desc = $6, category = $7, team = $8 WHERE id = $9`, [uploadId, existing.seen_count + 1, item.ip_address, item.device_type, extraStr, item.metric_desc, item.category, item.team, existing.id] ); recurringCount++; } else { await client.query( `INSERT INTO compliance_items (upload_id, hostname, ip_address, device_type, team, metric_id, metric_desc, category, extra_json, status, first_seen_upload_id, seen_count, vertical) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active', $10, 1, $11)`, [uploadId, item.hostname, item.ip_address, item.device_type, item.team, item.metric_id, item.metric_desc, item.category, extraStr, uploadId, vertical] ); newCount++; } } // 3. Resolve items NOT present in this upload — SCOPED to this vertical only for (const [key, row] of Object.entries(activeMap)) { if (!newKeys.has(key)) { await client.query( `UPDATE compliance_items SET status = 'resolved', resolved_upload_id = $1 WHERE id = $2`, [uploadId, row.id] ); resolvedCount++; } } // 4. Update upload with final counts await client.query( `UPDATE compliance_uploads SET new_count = $1, resolved_count = $2, recurring_count = $3 WHERE id = $4`, [newCount, resolvedCount, recurringCount, uploadId] ); // 5. Store summary entries in vcl_multi_vertical_summary if (summary && summary.entries && summary.entries.length > 0) { for (const entry of summary.entries) { await client.query( `INSERT INTO vcl_multi_vertical_summary (upload_id, vertical, metric_id, metric_desc, category, team, priority, non_compliant, compliant, total, compliance_pct, target, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, [uploadId, vertical, entry.metric_id, entry.description || entry.metric_desc || '', entry.category || 'Other', entry.team || '', entry.priority || '', entry.non_compliant || 0, entry.compliant || 0, entry.total || 0, entry.compliance_pct || 0, entry.target || 0, entry.status || ''] ); } } // 6. Create/update compliance_snapshots for this vertical // Use summary data for accurate totals (compliance_items only has non-compliant devices) const currentMonth = new Date().toISOString().slice(0, 7); let totalDevices = 0, snapshotCompliant = 0, snapshotNonCompliant = 0; if (summary && summary.entries && summary.entries.length > 0) { for (const entry of summary.entries) { totalDevices += entry.total || 0; snapshotCompliant += entry.compliant || 0; snapshotNonCompliant += entry.non_compliant || 0; } } const compPct = totalDevices > 0 ? Math.round((snapshotCompliant / totalDevices) * 100 * 100) / 100 : 0; await client.query( `INSERT INTO compliance_snapshots (snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (snapshot_month, vertical) DO UPDATE SET total_devices = $3, compliant = $4, non_compliant = $5, compliance_pct = $6`, [currentMonth, vertical, totalDevices, snapshotCompliant, snapshotNonCompliant, compPct] ); return { uploadId, newCount, recurringCount, resolvedCount }; } // --------------------------------------------------------------------------- // Safe temp path check // --------------------------------------------------------------------------- function isSafeTempPath(filePath) { const resolved = path.resolve(filePath); return resolved.startsWith(path.resolve(TEMP_DIR) + path.sep) && path.extname(resolved) === '.json'; } // --------------------------------------------------------------------------- // Router factory // --------------------------------------------------------------------------- function createVCLMultiVerticalRouter(upload) { const router = express.Router(); // All routes require authentication router.use(requireAuth()); /** * POST /preview * Accepts multiple xlsx files, parses each, extracts vertical from filename, * computes per-vertical scoped diffs, and stores parsed data in temp files. * * @method POST * @route /preview * @group Admin, Standard_User * * @body multipart/form-data * - files: File[] — 1–14 xlsx files with naming convention _YYYY_MM_DD.xlsx * * @response 200 * { * files: Array<{ * filename: string, * vertical: string, * report_date: string, * total_items: number, * summary_entries: number, * diff: { new_count: number, recurring_count: number, resolved_count: number }, * tempFile: string * }>, * unrecognized: Array<{ filename: string, error: string }> * } * @response 400 { error: string } — upload error or no files provided */ router.post('/preview', requireGroup('Admin', 'Standard_User'), (req, res) => { upload.array('files', 14)(req, res, async (uploadErr) => { if (uploadErr) return res.status(400).json({ error: uploadErr.message }); if (!req.files || req.files.length === 0) return res.status(400).json({ error: 'No files uploaded' }); const results = []; const unrecognized = []; const seenVerticals = new Set(); if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true }); for (const file of req.files) { const ext = path.extname(file.originalname).toLowerCase(); if (ext !== '.xlsx') { unrecognized.push({ filename: file.originalname, error: 'Not an xlsx file' }); fs.unlink(file.path, () => {}); continue; } // Extract vertical from filename const parsed = parseVerticalFilename(file.originalname); if (!parsed) { unrecognized.push({ filename: file.originalname, error: 'Filename does not match pattern _YYYY_MM_DD.xlsx' }); fs.unlink(file.path, () => {}); continue; } // Check for duplicate verticals in the same batch if (seenVerticals.has(parsed.vertical)) { unrecognized.push({ filename: file.originalname, error: `Duplicate vertical "${parsed.vertical}" in batch` }); fs.unlink(file.path, () => {}); continue; } seenVerticals.add(parsed.vertical); try { const xlsxData = await parseXlsx(file.path); if (xlsxData.error) { unrecognized.push({ filename: file.originalname, error: xlsxData.error }); fs.unlink(file.path, () => {}); continue; } // Compute scoped diff for this vertical const diff = await computeScopedDiff(xlsxData.items, parsed.vertical); // Store parsed data in temp file const tempFilename = `vcl_multi_${parsed.vertical}_${Date.now()}_${Math.random().toString(36).slice(2)}.json`; const tempFilePath = path.join(TEMP_DIR, tempFilename); fs.writeFileSync(tempFilePath, JSON.stringify({ items: xlsxData.items, summary: xlsxData.summary, report_date: parsed.date, vertical: parsed.vertical, filename: file.originalname.replace(/[^\w.\-() ]/g, '_'), })); results.push({ filename: file.originalname, vertical: parsed.vertical, report_date: parsed.date, total_items: xlsxData.total || xlsxData.items.length, summary_entries: (xlsxData.summary && xlsxData.summary.entries) ? xlsxData.summary.entries.length : 0, diff: { new_count: diff.newCount, recurring_count: diff.recurringCount, resolved_count: diff.resolvedCount }, tempFile: tempFilePath, }); } catch (parseErr) { unrecognized.push({ filename: file.originalname, error: parseErr.message }); } finally { fs.unlink(file.path, () => {}); } } res.json({ files: results, unrecognized }); }); }); /** * POST /commit * Commits all previewed files in a single transaction with vertical-scoped resolution. * * @method POST * @route /commit * @group Admin, Standard_User * * @body application/json * { * files: Array<{ * tempFile: string, * vertical?: string, * report_date?: string, * filename?: string * }> * } * * @response 200 * { * committed: Array<{ * vertical: string, * upload_id: number, * new_count: number, * recurring_count: number, * resolved_count: number * }>, * total_new: number, * total_resolved: number * } * @response 400 { error: string } — invalid/missing tempFile or expired preview session * @response 500 { error: string } — transaction failure */ router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => { const { files } = req.body; if (!files || !Array.isArray(files) || files.length === 0) { return res.status(400).json({ error: 'files array is required' }); } // Validate all temp files exist before starting transaction for (const file of files) { if (!file.tempFile || !isSafeTempPath(file.tempFile)) { return res.status(400).json({ error: `Invalid tempFile path for ${file.vertical || 'unknown'}` }); } if (!fs.existsSync(file.tempFile)) { return res.status(400).json({ error: `Preview session expired for ${file.vertical || 'unknown'} — please upload again` }); } } const client = await pool.connect(); try { await client.query('BEGIN'); const committed = []; for (const file of files) { let parsed; try { parsed = JSON.parse(fs.readFileSync(file.tempFile, 'utf8')); } catch { throw new Error(`Could not read preview data for ${file.vertical}`); } const result = await persistMultiVerticalUpload({ items: parsed.items, summary: parsed.summary, reportDate: file.report_date || parsed.report_date, filename: file.filename || parsed.filename, vertical: file.vertical || parsed.vertical, userId: req.user?.id || null, }, client); committed.push({ vertical: file.vertical || parsed.vertical, upload_id: result.uploadId, new_count: result.newCount, recurring_count: result.recurringCount, resolved_count: result.resolvedCount, }); } await client.query('COMMIT'); // Clean up temp files for (const file of files) { fs.unlink(file.tempFile, () => {}); } // Audit log logAudit({ userId: req.user.id, username: req.user.username, action: 'vcl_multi_vertical_upload', entityType: 'compliance_uploads', entityId: null, details: { verticals: committed.map(c => c.vertical), total_new: committed.reduce((s, c) => s + c.new_count, 0), total_resolved: committed.reduce((s, c) => s + c.resolved_count, 0), }, ipAddress: req.ip, }); res.json({ committed, total_new: committed.reduce((s, c) => s + c.new_count, 0), total_resolved: committed.reduce((s, c) => s + c.resolved_count, 0), }); } catch (err) { await client.query('ROLLBACK'); // Clean up temp files on failure too for (const file of files) { if (file.tempFile) fs.unlink(file.tempFile, () => {}); } console.error('[VCL Multi] Commit error:', err.message); res.status(500).json({ error: 'Failed to commit batch: ' + err.message }); } finally { client.release(); } }); /** * GET /stats * Returns aggregated cross-vertical executive summary statistics. * * @method GET * @route /stats * * @response 200 * { * stats: { * total_devices: number, * compliant: number, * non_compliant: number, * compliance_pct: number, * target_pct: number * }, * donut: { blocked: number, in_progress: number }, * vertical_breakdown: Array<{ * vertical: string, * total_devices: number, * compliant: number, * non_compliant: number, * compliance_pct: number, * blockers: number, * forecast_burndown: Array<{ month: string, projected_remaining: number }>, * last_upload: string|null * }>, * last_upload_date: string|null * } * @response 500 { error: string } */ router.get('/stats', async (req, res) => { try { // Use Summary sheet data (vcl_multi_vertical_summary) for accurate totals. // The compliance_items table only contains NON-COMPLIANT devices, so counting // hostnames there gives 0 compliant. The Summary sheet has the real numbers. // Get the latest upload per vertical to pull summary data from const { rows: latestUploads } = await pool.query(` SELECT DISTINCT ON (vertical) id, vertical FROM compliance_uploads WHERE vertical IS NOT NULL ORDER BY vertical, id DESC `); if (latestUploads.length === 0) { return res.json({ stats: { total_devices: 0, compliant: 0, non_compliant: 0, compliance_pct: 0, target_pct: VCL_TARGET_PCT }, donut: { blocked: { count: 0, pct: 0 }, in_progress: { count: 0, pct: 0 } }, vertical_breakdown: [], last_upload_date: null, }); } const latestUploadIds = latestUploads.map(u => u.id); // Aggregate summary data from the latest upload per vertical // Each row in vcl_multi_vertical_summary is one metric for one vertical. // We sum across metrics per vertical to get vertical-level totals. const { rows: verticalSummary } = await pool.query(` SELECT vertical, SUM(total)::int AS total_devices, SUM(compliant)::int AS compliant, SUM(non_compliant)::int AS non_compliant FROM vcl_multi_vertical_summary WHERE upload_id = ANY($1) GROUP BY vertical ORDER BY vertical `, [latestUploadIds]); // Compute aggregated stats across all verticals let aggTotal = 0, aggCompliant = 0, aggNonCompliant = 0; for (const v of verticalSummary) { aggTotal += v.total_devices; aggCompliant += v.compliant; aggNonCompliant += v.non_compliant; } const compliance_pct = aggTotal > 0 ? Math.round((aggCompliant / aggTotal) * 100) : 0; // Donut: blocked vs in-progress (from compliance_items — devices with/without resolution dates) const { rows: donutRows } = await pool.query(` SELECT hostname, MAX(resolution_date) AS resolution_date FROM compliance_items WHERE vertical IS NOT NULL AND status = 'active' GROUP BY hostname `); const donutItems = donutRows.map(r => ({ resolution_date: r.resolution_date })); const donut = categorizeNonCompliant(donutItems); // Get last upload date per vertical const { rows: uploadDates } = await pool.query(` SELECT vertical, MAX(report_date) AS last_upload FROM compliance_uploads WHERE vertical IS NOT NULL GROUP BY vertical `); const uploadDateMap = {}; uploadDates.forEach(r => { uploadDateMap[r.vertical] = r.last_upload; }); // Get burndown data per vertical (from compliance_items — actual device records) const { rows: burndownRows } = await pool.query(` SELECT vertical, hostname, resolution_date FROM compliance_items WHERE vertical IS NOT NULL AND status = 'active' `); const burndownByVertical = {}; for (const row of burndownRows) { if (!burndownByVertical[row.vertical]) burndownByVertical[row.vertical] = []; burndownByVertical[row.vertical].push(row); } // Build per-vertical breakdown using summary data for totals const vertical_breakdown = verticalSummary.map(v => { const pct = v.total_devices > 0 ? Math.round((v.compliant / v.total_devices) * 100) : 0; const items = burndownByVertical[v.vertical] || []; const burndown = computeVerticalBurndown(items); return { vertical: v.vertical, total_devices: v.total_devices, compliant: v.compliant, non_compliant: v.non_compliant, compliance_pct: pct, blockers: burndown.blockers, forecast_burndown: burndown.monthly, last_upload: uploadDateMap[v.vertical] || null, }; }); res.json({ stats: { total_devices: aggTotal, compliant: aggCompliant, non_compliant: aggNonCompliant, compliance_pct, target_pct: VCL_TARGET_PCT, }, donut, vertical_breakdown, last_upload_date: uploadDates.length > 0 ? uploadDates.reduce((max, r) => r.last_upload > max ? r.last_upload : max, '') : null, }); } catch (err) { console.error('[VCL Multi] GET /stats error:', err.message); res.status(500).json({ error: 'Database error' }); } }); /** * GET /trend * Returns monthly compliance trend data aggregated across all verticals. * Includes linear regression forecast when 3+ months of data exist. * * @method GET * @route /trend * * @response 200 * { * months: Array<{ * month: string, * compliant_count: number|null, * compliance_pct: number|null, * forecast_pct: number|null, * target_pct: number * }> * } * @response 500 { error: string } */ router.get('/trend', async (req, res) => { try { // Get snapshots for multi-vertical data (vertical IS NOT NULL) const { rows: snapshots } = await pool.query(` SELECT snapshot_month, SUM(total_devices)::int AS total_devices, SUM(compliant)::int AS compliant, SUM(non_compliant)::int AS non_compliant FROM compliance_snapshots WHERE vertical IS NOT NULL AND vertical != '' GROUP BY snapshot_month ORDER BY snapshot_month ASC `); if (snapshots.length === 0) return res.json({ months: [] }); const months = snapshots.map(s => { const total = s.total_devices; const pct = total > 0 ? Math.round((s.compliant / total) * 100 * 10) / 10 : 0; return { month: s.snapshot_month, compliant_count: s.compliant, compliance_pct: pct, forecast_pct: null, target_pct: VCL_TARGET_PCT, }; }); // Compute forecast using linear regression if we have 3+ months if (months.length >= 3) { const n = months.length; let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; for (let i = 0; i < n; i++) { sumX += i; sumY += months[i].compliance_pct; sumXY += i * months[i].compliance_pct; sumX2 += i * i; } const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); const intercept = (sumY - slope * sumX) / n; // Project forward 3 months for (let i = 0; i < 3; i++) { const futureIdx = n + i; const forecastPct = Math.min(100, Math.max(0, Math.round((slope * futureIdx + intercept) * 10) / 10)); const lastMonth = months[months.length - 1].month; const [year, mon] = lastMonth.split('-').map(Number); const futureDate = new Date(year, mon - 1 + i + 1, 1); const futureMonth = `${futureDate.getFullYear()}-${String(futureDate.getMonth() + 1).padStart(2, '0')}`; months.push({ month: futureMonth, compliant_count: null, compliance_pct: null, forecast_pct: forecastPct, target_pct: VCL_TARGET_PCT, }); } // Add forecast_pct to last actual month as starting point if (n > 0) { months[n - 1].forecast_pct = months[n - 1].compliance_pct; } } res.json({ months }); } catch (err) { console.error('[VCL Multi] GET /trend error:', err.message); res.status(500).json({ error: 'Database error' }); } }); /** * GET /vertical/:code/metrics * Returns per-metric breakdown for a specific vertical from the latest upload's summary data. * * @method GET * @route /vertical/:code/metrics * @param {string} code — vertical code (e.g., "NTS_AEO", "SDIT_CISO") * * @response 200 * { * vertical: string, * metrics: Array<{ * metric_id: string, * metric_desc: string, * category: string, * team: string, * priority: string, * non_compliant: number, * compliant: number, * total: number, * compliance_pct: number, * target: number, * status: string * }>, * categories: Array<{ * category: string, * non_compliant: number, * compliant: number, * total: number, * compliance_pct: number * }> * } * @response 400 { error: string } — invalid vertical code * @response 500 { error: string } */ router.get('/vertical/:code/metrics', async (req, res) => { const vertical = req.params.code; if (!vertical || vertical.length > 100) return res.status(400).json({ error: 'Invalid vertical code' }); try { // Get the latest upload for this vertical const { rows: latestUpload } = await pool.query( `SELECT id FROM compliance_uploads WHERE vertical = $1 ORDER BY id DESC LIMIT 1`, [vertical] ); if (latestUpload.length === 0) return res.json({ vertical, metrics: [], categories: [] }); const uploadId = latestUpload[0].id; // Get per-metric summary data const { rows: metrics } = await pool.query( `SELECT metric_id, metric_desc, category, team, priority, non_compliant, compliant, total, compliance_pct, target, status FROM vcl_multi_vertical_summary WHERE upload_id = $1 AND vertical = $2 ORDER BY category, metric_id`, [uploadId, vertical] ); // Aggregate by category const categoryMap = {}; for (const m of metrics) { const cat = m.category || 'Other'; if (!categoryMap[cat]) categoryMap[cat] = { category: cat, non_compliant: 0, compliant: 0, total: 0 }; categoryMap[cat].non_compliant += m.non_compliant; categoryMap[cat].compliant += m.compliant; categoryMap[cat].total += m.total; } const categories = Object.values(categoryMap).map(c => ({ ...c, compliance_pct: c.total > 0 ? Math.round((c.compliant / c.total) * 100 * 10) / 10 : 0, })); res.json({ vertical, metrics, categories }); } catch (err) { console.error('[VCL Multi] GET /vertical/:code/metrics error:', err.message); res.status(500).json({ error: 'Database error' }); } }); /** * GET /vertical/:code/metric/:metricId/devices * Returns the list of non-compliant devices for a specific vertical + metric. * * @method GET * @route /vertical/:code/metric/:metricId/devices * @param {string} code — vertical code (e.g., "NTS_AEO") * @param {string} metricId — metric identifier (e.g., "VM-001") * * @response 200 * { * vertical: string, * metric_id: string, * devices: Array<{ * hostname: string, * ip_address: string, * device_type: string, * team: string, * seen_count: number, * resolution_date: string|null, * remediation_plan: string|null, * first_seen: string|null, * last_seen: string|null * }> * } * @response 400 { error: string } — invalid vertical code or metric ID * @response 500 { error: string } */ router.get('/vertical/:code/metric/:metricId/devices', async (req, res) => { const vertical = req.params.code; const metricId = req.params.metricId; if (!vertical || vertical.length > 100) return res.status(400).json({ error: 'Invalid vertical code' }); if (!metricId || metricId.length > 50) return res.status(400).json({ error: 'Invalid metric ID' }); try { const { rows } = await pool.query( `SELECT ci.hostname, ci.ip_address, ci.device_type, ci.team, ci.seen_count, ci.resolution_date, ci.remediation_plan, fu.report_date AS first_seen, lu.report_date AS last_seen FROM compliance_items ci LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id WHERE ci.vertical = $1 AND ci.metric_id = $2 AND ci.status = 'active' ORDER BY ci.hostname`, [vertical, metricId] ); res.json({ vertical, metric_id: metricId, devices: rows }); } catch (err) { console.error('[VCL Multi] GET /vertical/:code/metric/:metricId/devices error:', err.message); res.status(500).json({ error: 'Database error' }); } }); /** * GET /vertical/:code/burndown * Returns burndown forecast for a specific vertical. * Deduplicates devices by hostname and computes monthly projected resolution. * * @method GET * @route /vertical/:code/burndown * @param {string} code — vertical code (e.g., "TSI") * * @response 200 * { * vertical: string, * blockers: number, * in_progress: number, * monthly: Array<{ month: string, projected_remaining: number }> * } * @response 400 { error: string } — invalid vertical code * @response 500 { error: string } */ router.get('/vertical/:code/burndown', async (req, res) => { const vertical = req.params.code; if (!vertical || vertical.length > 100) return res.status(400).json({ error: 'Invalid vertical code' }); try { const { rows } = await pool.query( `SELECT hostname, resolution_date FROM compliance_items WHERE vertical = $1 AND status = 'active'`, [vertical] ); // Deduplicate by hostname (a device may have multiple failing metrics) const deviceMap = {}; for (const row of rows) { if (!deviceMap[row.hostname]) { deviceMap[row.hostname] = { hostname: row.hostname, resolution_date: row.resolution_date }; } else if (row.resolution_date && !deviceMap[row.hostname].resolution_date) { // If any metric has a resolution date, the device counts as "in progress" deviceMap[row.hostname].resolution_date = row.resolution_date; } } const devices = Object.values(deviceMap); const burndown = computeVerticalBurndown(devices); res.json({ vertical, ...burndown }); } catch (err) { console.error('[VCL Multi] GET /vertical/:code/burndown error:', err.message); res.status(500).json({ error: 'Database error' }); } }); /** * GET /uploads * Returns upload history for multi-vertical uploads (most recent 100). * * @method GET * @route /uploads * * @response 200 * { * uploads: Array<{ * id: number, * filename: string, * report_date: string|null, * uploaded_at: string, * vertical: string, * new_count: number, * resolved_count: number, * recurring_count: number * }> * } * @response 500 { error: string } */ router.get('/uploads', async (req, res) => { try { const { rows } = await pool.query( `SELECT id, filename, report_date, uploaded_at, vertical, new_count, resolved_count, recurring_count FROM compliance_uploads WHERE vertical IS NOT NULL ORDER BY id DESC LIMIT 100` ); res.json({ uploads: rows }); } catch (err) { console.error('[VCL Multi] GET /uploads error:', err.message); res.status(500).json({ error: 'Database error' }); } }); /** * GET /verticals * Returns the list of known verticals derived from compliance_items records. * * @method GET * @route /verticals * * @response 200 * { * verticals: string[] * } * @response 500 { error: string } */ router.get('/verticals', async (req, res) => { try { const { rows } = await pool.query( `SELECT DISTINCT vertical FROM compliance_items WHERE vertical IS NOT NULL ORDER BY vertical` ); res.json({ verticals: rows.map(r => r.vertical) }); } catch (err) { console.error('[VCL Multi] GET /verticals error:', err.message); res.status(500).json({ error: 'Database error' }); } }); // ----------------------------------------------------------------------- // Data Management — Delete / Rollback // ----------------------------------------------------------------------- /** * DELETE /vertical/:code * Deletes all data for a single vertical — items, uploads, summary, and snapshots. * Admin only. * * @method DELETE * @route /vertical/:code * @group Admin * @param {string} code — vertical code to delete (e.g., "NTS_AEO") * * @response 200 * { * message: string, * deleted: { items: number, uploads: number } * } * @response 400 { error: string } — invalid vertical code * @response 500 { error: string } */ router.delete('/vertical/:code', requireGroup('Admin'), async (req, res) => { const vertical = req.params.code; if (!vertical || vertical.length > 100) return res.status(400).json({ error: 'Invalid vertical code' }); const client = await pool.connect(); try { await client.query('BEGIN'); const { rows: uploadRows } = await client.query( `SELECT id FROM compliance_uploads WHERE vertical = $1`, [vertical] ); const uploadIds = uploadRows.map(r => r.id); if (uploadIds.length > 0) { await client.query( `DELETE FROM vcl_multi_vertical_summary WHERE upload_id = ANY($1)`, [uploadIds] ); } const itemResult = await client.query( `DELETE FROM compliance_items WHERE vertical = $1`, [vertical] ); const uploadResult = await client.query( `DELETE FROM compliance_uploads WHERE vertical = $1`, [vertical] ); await client.query( `DELETE FROM compliance_snapshots WHERE vertical = $1`, [vertical] ); await client.query('COMMIT'); logAudit({ userId: req.user.id, username: req.user.username, action: 'vcl_multi_vertical_delete', entityType: 'compliance_vertical', entityId: vertical, details: { items_deleted: itemResult.rowCount, uploads_deleted: uploadResult.rowCount }, ipAddress: req.ip, }); res.json({ message: `Deleted all data for vertical "${vertical}"`, deleted: { items: itemResult.rowCount, uploads: uploadResult.rowCount }, }); } catch (err) { await client.query('ROLLBACK'); console.error('[VCL Multi] DELETE /vertical/:code error:', err.message); res.status(500).json({ error: 'Failed to delete vertical data' }); } finally { client.release(); } }); /** * DELETE /all * Deletes ALL multi-vertical data — items, uploads, summary, and snapshots. * Admin only. Nuclear reset. * * @method DELETE * @route /all * @group Admin * * @response 200 * { * message: string, * deleted: { items: number, uploads: number } * } * @response 500 { error: string } */ router.delete('/all', requireGroup('Admin'), async (req, res) => { const client = await pool.connect(); try { await client.query('BEGIN'); await client.query(`DELETE FROM vcl_multi_vertical_summary`); const itemResult = await client.query(`DELETE FROM compliance_items WHERE vertical IS NOT NULL`); const uploadResult = await client.query(`DELETE FROM compliance_uploads WHERE vertical IS NOT NULL`); await client.query(`DELETE FROM compliance_snapshots WHERE vertical IS NOT NULL AND vertical != ''`); await client.query('COMMIT'); logAudit({ userId: req.user.id, username: req.user.username, action: 'vcl_multi_vertical_reset', entityType: 'compliance_vertical', entityId: 'ALL', details: { items_deleted: itemResult.rowCount, uploads_deleted: uploadResult.rowCount }, ipAddress: req.ip, }); res.json({ message: 'All multi-vertical data has been deleted', deleted: { items: itemResult.rowCount, uploads: uploadResult.rowCount }, }); } catch (err) { await client.query('ROLLBACK'); console.error('[VCL Multi] DELETE /all error:', err.message); res.status(500).json({ error: 'Failed to reset data' }); } finally { client.release(); } }); /** * DELETE /upload/:uploadId * Rolls back a specific upload — deletes items introduced by it, reactivates items it resolved. * Admin only. Must be the most recent upload for that vertical. * * @method DELETE * @route /upload/:uploadId * @group Admin * @param {number} uploadId — numeric ID of the upload to roll back * * @response 200 * { * message: string, * rolled_back: { * upload_id: number, * vertical: string, * filename: string, * items_deleted: number, * items_reactivated: number * } * } * @response 400 { error: string } — invalid upload ID, not a multi-vertical upload, or not the most recent upload * @response 404 { error: string } — upload not found * @response 500 { error: string } */ router.delete('/upload/:uploadId', requireGroup('Admin'), async (req, res) => { const uploadId = parseInt(req.params.uploadId, 10); if (isNaN(uploadId)) return res.status(400).json({ error: 'Invalid upload ID' }); try { const { rows: uploadRows } = await pool.query( `SELECT id, filename, report_date, vertical, new_count, resolved_count FROM compliance_uploads WHERE id = $1`, [uploadId] ); const upload = uploadRows[0]; if (!upload) return res.status(404).json({ error: 'Upload not found' }); if (!upload.vertical) return res.status(400).json({ error: 'This upload is not a multi-vertical upload' }); const { rows: latestRows } = await pool.query( `SELECT id FROM compliance_uploads WHERE vertical = $1 ORDER BY id DESC LIMIT 1`, [upload.vertical] ); if (latestRows[0].id !== uploadId) { return res.status(400).json({ error: `Only the most recent upload for "${upload.vertical}" can be rolled back`, latest_upload_id: latestRows[0].id, }); } const client = await pool.connect(); try { await client.query('BEGIN'); const deleteNew = await client.query( `DELETE FROM compliance_items WHERE first_seen_upload_id = $1 AND upload_id = $1`, [uploadId] ); const reactivate = await client.query( `UPDATE compliance_items SET status = 'active', resolved_upload_id = NULL WHERE resolved_upload_id = $1`, [uploadId] ); const { rows: prevRows } = await pool.query( `SELECT id FROM compliance_uploads WHERE vertical = $1 AND id < $2 ORDER BY id DESC LIMIT 1`, [upload.vertical, uploadId] ); if (prevRows.length > 0) { await client.query( `UPDATE compliance_items SET upload_id = $1, seen_count = GREATEST(seen_count - 1, 1) WHERE upload_id = $2 AND first_seen_upload_id != $2`, [prevRows[0].id, uploadId] ); } await client.query(`DELETE FROM vcl_multi_vertical_summary WHERE upload_id = $1`, [uploadId]); await client.query(`DELETE FROM compliance_uploads WHERE id = $1`, [uploadId]); const currentMonth = new Date().toISOString().slice(0, 7); await client.query( `DELETE FROM compliance_snapshots WHERE vertical = $1 AND snapshot_month = $2`, [upload.vertical, currentMonth] ); await client.query('COMMIT'); logAudit({ userId: req.user.id, username: req.user.username, action: 'vcl_multi_upload_rollback', entityType: 'compliance_upload', entityId: String(uploadId), details: { vertical: upload.vertical, filename: upload.filename, items_deleted: deleteNew.rowCount, items_reactivated: reactivate.rowCount, }, ipAddress: req.ip, }); res.json({ message: `Rolled back upload "${upload.filename}" for ${upload.vertical}`, rolled_back: { upload_id: uploadId, vertical: upload.vertical, filename: upload.filename, items_deleted: deleteNew.rowCount, items_reactivated: reactivate.rowCount, }, }); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } } catch (err) { console.error('[VCL Multi] DELETE /upload/:uploadId error:', err.message); res.status(500).json({ error: 'Failed to rollback upload: ' + err.message }); } }); return router; } module.exports = { createVCLMultiVerticalRouter };