Add CCP Metrics page with multi-vertical VCL upload and cross-org reporting
New feature: multi-file per-vertical compliance xlsx upload with scoped resolution logic, executive-level aggregated reporting, and drill-down by vertical and metric. Supports daily upload cadence and batch commit. Backend: - Migration: add vertical column to compliance_items/uploads, create vcl_multi_vertical_summary table - New route module: routes/vclMultiVertical.js with preview, commit, stats, trend, metric drill-down, device list, and burndown endpoints - New helpers: parseVerticalFilename(), computeVerticalBurndown() - Vertical-scoped resolution: uploading one vertical never resolves items from other verticals Frontend: - CCPMetricsPage with stats bar, trend chart, donut, vertical table - Drill-down: vertical -> metrics by category -> device list - Per-vertical burndown forecast chart - MultiVerticalUploadModal: multi-file drag-drop, batch preview, commit - Nav entry: CCP Metrics (Building2 icon) Docs: - Design brief for stakeholder meeting (docs/vcl-multi-vertical-design-brief.md)
This commit is contained in:
@@ -205,6 +205,79 @@ function mapColumnHeaders(headers) {
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts vertical code and report date from a filename.
|
||||
* Pattern: <VERTICAL>_YYYY_MM_DD.xlsx
|
||||
* The vertical is everything before the trailing _YYYY_MM_DD portion.
|
||||
*
|
||||
* Examples:
|
||||
* NTS_AEO_2026_05_11.xlsx → { vertical: 'NTS_AEO', date: '2026-05-11' }
|
||||
* SDIT_CISO_2026_05_11.xlsx → { vertical: 'SDIT_CISO', date: '2026-05-11' }
|
||||
* SR_2026_05_11.xlsx → { vertical: 'SR', date: '2026-05-11' }
|
||||
* AllOthers_2026_05_11.xlsx → { vertical: 'AllOthers', date: '2026-05-11' }
|
||||
*
|
||||
* Returns null if the filename does not match the expected pattern.
|
||||
*/
|
||||
function parseVerticalFilename(filename) {
|
||||
// Strip .xlsx extension (case-insensitive)
|
||||
const stem = filename.replace(/\.xlsx$/i, '');
|
||||
// Match: everything up to the last _YYYY_MM_DD
|
||||
const match = stem.match(/^(.+?)_(\d{4})_(\d{2})_(\d{2})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const vertical = match[1];
|
||||
const date = `${match[2]}-${match[3]}-${match[4]}`;
|
||||
|
||||
// Validate the date portion is a real date
|
||||
if (!isValidDateString(date)) return null;
|
||||
|
||||
return { vertical, date };
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes per-vertical burndown forecast from non-compliant items.
|
||||
* Returns breakdown of items with/without resolution dates and monthly projections.
|
||||
*/
|
||||
function computeVerticalBurndown(items) {
|
||||
const total = items.length;
|
||||
const withDates = items.filter(i => i.resolution_date != null);
|
||||
const blockers = items.filter(i => i.resolution_date == null);
|
||||
|
||||
// Bucket by month
|
||||
const monthly = {};
|
||||
for (const item of withDates) {
|
||||
const dateStr = typeof item.resolution_date === 'string'
|
||||
? item.resolution_date
|
||||
: item.resolution_date.toISOString().slice(0, 10);
|
||||
const month = dateStr.slice(0, 7); // YYYY-MM
|
||||
monthly[month] = (monthly[month] || 0) + 1;
|
||||
}
|
||||
|
||||
// Cumulative projection — how many remain after each month
|
||||
let remaining = total;
|
||||
const projection = {};
|
||||
for (const month of Object.keys(monthly).sort()) {
|
||||
remaining -= monthly[month];
|
||||
projection[month] = { remediated: monthly[month], remaining };
|
||||
}
|
||||
|
||||
// Projected clear date — first month where remaining hits 0 (excluding blockers)
|
||||
let projectedClearDate = null;
|
||||
if (blockers.length === 0 && Object.keys(projection).length > 0) {
|
||||
const sortedMonths = Object.keys(projection).sort();
|
||||
projectedClearDate = sortedMonths[sortedMonths.length - 1];
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
blockers: blockers.length,
|
||||
with_dates: withDates.length,
|
||||
monthly,
|
||||
projection,
|
||||
projected_clear_date: projectedClearDate,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
truncateText,
|
||||
validateRemediationPlan,
|
||||
@@ -217,4 +290,6 @@ module.exports = {
|
||||
matchByHostname,
|
||||
computeBulkDiff,
|
||||
mapColumnHeaders,
|
||||
parseVerticalFilename,
|
||||
computeVerticalBurndown,
|
||||
};
|
||||
|
||||
65
backend/migrations/add_vcl_multi_vertical.js
Normal file
65
backend/migrations/add_vcl_multi_vertical.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// Migration: Add multi-vertical support for VCL compliance reporting
|
||||
// Adds vertical column to compliance_items and compliance_uploads,
|
||||
// creates vcl_multi_vertical_summary table for per-vertical metric data.
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting VCL multi-vertical migration...');
|
||||
try {
|
||||
// Add vertical column to compliance_items
|
||||
await pool.query(`ALTER TABLE compliance_items ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL`);
|
||||
console.log('✓ vertical column added to compliance_items');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical ON compliance_items(vertical)`);
|
||||
console.log('✓ idx_compliance_items_vertical index created');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical_status ON compliance_items(vertical, status)`);
|
||||
console.log('✓ idx_compliance_items_vertical_status index created');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical_metric ON compliance_items(vertical, metric_id, status)`);
|
||||
console.log('✓ idx_compliance_items_vertical_metric index created');
|
||||
|
||||
// Add vertical column to compliance_uploads
|
||||
await pool.query(`ALTER TABLE compliance_uploads ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL`);
|
||||
console.log('✓ vertical column added to compliance_uploads');
|
||||
|
||||
// Create summary table for per-vertical metric data from Summary sheets
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS vcl_multi_vertical_summary (
|
||||
id SERIAL PRIMARY KEY,
|
||||
upload_id INTEGER NOT NULL REFERENCES compliance_uploads(id) ON DELETE CASCADE,
|
||||
vertical TEXT NOT NULL,
|
||||
metric_id TEXT NOT NULL,
|
||||
metric_desc TEXT DEFAULT '',
|
||||
category TEXT DEFAULT 'Other',
|
||||
team TEXT DEFAULT '',
|
||||
priority TEXT DEFAULT '',
|
||||
non_compliant INTEGER DEFAULT 0,
|
||||
compliant INTEGER DEFAULT 0,
|
||||
total INTEGER DEFAULT 0,
|
||||
compliance_pct NUMERIC(5,2) DEFAULT 0,
|
||||
target NUMERIC(5,2) DEFAULT 0,
|
||||
status TEXT DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
console.log('✓ vcl_multi_vertical_summary table created');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_vertical ON vcl_multi_vertical_summary(vertical)`);
|
||||
console.log('✓ idx_vcl_multi_summary_vertical index created');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_upload ON vcl_multi_vertical_summary(upload_id)`);
|
||||
console.log('✓ idx_vcl_multi_summary_upload index created');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_vertical_metric ON vcl_multi_vertical_summary(vertical, metric_id)`);
|
||||
console.log('✓ idx_vcl_multi_summary_vertical_metric index created');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Migration error:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Migration complete.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -19,6 +19,7 @@ const POSTGRES_MIGRATIONS = [
|
||||
'add_fp_submissions_requeued_at.js',
|
||||
'add_vcl_reporting_columns.js',
|
||||
'add_vcl_vertical_metadata.js',
|
||||
'add_vcl_multi_vertical.js',
|
||||
];
|
||||
|
||||
async function runAll() {
|
||||
|
||||
880
backend/routes/vclMultiVertical.js
Normal file
880
backend/routes/vclMultiVertical.js
Normal file
@@ -0,0 +1,880 @@
|
||||
// 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
|
||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||
const { rows: verticalStats } = await client.query(
|
||||
`SELECT
|
||||
COUNT(DISTINCT hostname)::int AS total_devices,
|
||||
COUNT(DISTINCT CASE WHEN status = 'active' THEN hostname END)::int AS non_compliant
|
||||
FROM compliance_items
|
||||
WHERE vertical = $1`,
|
||||
[vertical]
|
||||
);
|
||||
const vs = verticalStats[0] || { total_devices: 0, non_compliant: 0 };
|
||||
const totalDevices = vs.total_devices;
|
||||
const compliant = totalDevices - vs.non_compliant;
|
||||
const compPct = totalDevices > 0 ? Math.round((compliant / 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, compliant, vs.non_compliant, 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 <VERTICAL>_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 <VERTICAL>_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 {
|
||||
// Aggregate device-level stats across all multi-vertical items
|
||||
const { rows: statsRows } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(DISTINCT hostname)::int AS total_devices,
|
||||
COUNT(DISTINCT CASE WHEN status = 'active' THEN hostname END)::int AS non_compliant
|
||||
FROM compliance_items
|
||||
WHERE vertical IS NOT NULL
|
||||
`);
|
||||
|
||||
const raw = statsRows[0] || { total_devices: 0, non_compliant: 0 };
|
||||
const total_devices = raw.total_devices;
|
||||
const non_compliant = raw.non_compliant;
|
||||
const compliant = total_devices - non_compliant;
|
||||
const compliance_pct = total_devices > 0 ? Math.round((compliant / total_devices) * 100) : 0;
|
||||
|
||||
// Donut: blocked vs in-progress across all verticals
|
||||
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);
|
||||
|
||||
// Per-vertical breakdown
|
||||
const { rows: verticalRows } = await pool.query(`
|
||||
SELECT
|
||||
vertical,
|
||||
COUNT(DISTINCT hostname)::int AS total_devices,
|
||||
COUNT(DISTINCT CASE WHEN status = 'active' THEN hostname END)::int AS non_compliant
|
||||
FROM compliance_items
|
||||
WHERE vertical IS NOT NULL
|
||||
GROUP BY vertical
|
||||
ORDER BY vertical
|
||||
`);
|
||||
|
||||
// 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
|
||||
const { rows: burndownRows } = await pool.query(`
|
||||
SELECT vertical, hostname, resolution_date
|
||||
FROM compliance_items
|
||||
WHERE vertical IS NOT NULL AND status = 'active'
|
||||
`);
|
||||
// Group by vertical for burndown computation
|
||||
const burndownByVertical = {};
|
||||
for (const row of burndownRows) {
|
||||
if (!burndownByVertical[row.vertical]) burndownByVertical[row.vertical] = [];
|
||||
burndownByVertical[row.vertical].push(row);
|
||||
}
|
||||
|
||||
const vertical_breakdown = verticalRows.map(v => {
|
||||
const totalDev = v.total_devices;
|
||||
const comp = totalDev - v.non_compliant;
|
||||
const pct = totalDev > 0 ? Math.round((comp / totalDev) * 100) : 0;
|
||||
const items = burndownByVertical[v.vertical] || [];
|
||||
const burndown = computeVerticalBurndown(items);
|
||||
|
||||
return {
|
||||
vertical: v.vertical,
|
||||
total_devices: totalDev,
|
||||
compliant: comp,
|
||||
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,
|
||||
compliant,
|
||||
non_compliant,
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createVCLMultiVerticalRouter };
|
||||
@@ -28,6 +28,7 @@ const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
||||
const createIvantiArchiveRouter = require('./routes/ivantiArchive');
|
||||
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
|
||||
const { createComplianceRouter } = require('./routes/compliance');
|
||||
const { createVCLMultiVerticalRouter } = require('./routes/vclMultiVertical');
|
||||
const createAtlasRouter = require('./routes/atlas');
|
||||
const createJiraTicketsRouter = require('./routes/jiraTickets');
|
||||
const createCardApiRouter = require('./routes/cardApi');
|
||||
@@ -213,6 +214,9 @@ app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter());
|
||||
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
|
||||
app.use('/api/compliance', createComplianceRouter(upload));
|
||||
|
||||
// VCL multi-vertical routes — cross-organizational compliance reporting
|
||||
app.use('/api/compliance/vcl-multi', createVCLMultiVerticalRouter(upload));
|
||||
|
||||
// Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges
|
||||
app.use('/api/atlas', createAtlasRouter());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user