feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
// Compliance Routes — AEO metric tracking
// Handles xlsx upload/parse, non-compliant item history, and notes.
const express = require ( 'express' ) ;
const path = require ( 'path' ) ;
const fs = require ( 'fs' ) ;
2026-04-16 14:28:44 -06:00
const crypto = require ( 'crypto' ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
const { spawn } = require ( 'child_process' ) ;
2026-05-06 11:44:17 -06:00
const pool = require ( '../db' ) ;
const { requireAuth , requireGroup } = require ( '../middleware/auth' ) ;
2026-04-20 20:12:12 +00:00
const { loadConfig , compareSchemaToDrift , reconcileConfig } = require ( '../helpers/driftChecker' ) ;
2026-05-11 15:48:10 -06:00
const { isValidDateString , validateRemediationPlan , computeVCLStats , categorizeNonCompliant , rankHeavyHitters , computeForecastBurndown , matchByHostname , computeBulkDiff , mapColumnHeaders } = require ( '../helpers/vclHelpers' ) ;
2026-04-20 20:12:12 +00:00
const logAudit = require ( '../helpers/auditLog' ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
2026-04-20 20:12:12 +00:00
const PARSER _SCRIPT = path . join ( _ _dirname , '../scripts/parse_compliance_xlsx.py' ) ;
const SCHEMA _SCRIPT = path . join ( _ _dirname , '../scripts/extract_xlsx_schema.py' ) ;
const CONFIG _PATH = path . join ( _ _dirname , '..' , 'scripts' , 'compliance_config.json' ) ;
const PYTHON _BIN = process . env . PYTHON _BIN || 'python3' ;
const TEMP _DIR = path . join ( process . cwd ( ) , 'uploads' , 'temp' ) ;
const ALLOWED _TEAMS = new Set ( [ 'STEAM' , 'ACCESS-ENG' , 'ACCESS-OPS' , 'INTELDEV' ] ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
// ---------------------------------------------------------------------------
// Run Python parser, return parsed object
// ---------------------------------------------------------------------------
function parseXlsx ( filePath ) {
return new Promise ( ( resolve , reject ) => {
2026-04-01 12:47:50 -06:00
const py = spawn ( PYTHON _BIN , [ PARSER _SCRIPT , filePath ] ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
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 ) ;
} ) ;
}
2026-04-20 20:12:12 +00:00
function extractXlsxSchema ( filePath ) {
return new Promise ( ( resolve , reject ) => {
const py = spawn ( PYTHON _BIN , [ SCHEMA _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 || ` Schema extractor exited with code ${ code } ` ) ) ;
try { resolve ( JSON . parse ( out ) ) ; }
catch ( e ) { reject ( new Error ( 'Schema extractor returned invalid JSON' ) ) ; }
} ) ;
py . on ( 'error' , reject ) ;
} ) ;
}
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
function isSafeTempPath ( filePath ) {
const resolved = path . resolve ( filePath ) ;
return resolved . startsWith ( TEMP _DIR + path . sep ) && path . extname ( resolved ) === '.json' ;
}
// ---------------------------------------------------------------------------
// Compute diff: new / recurring / resolved
// ---------------------------------------------------------------------------
2026-05-06 11:44:17 -06:00
async function computeDiff ( incomingItems ) {
const { rows : activeRows } = await pool . query (
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
` SELECT hostname, metric_id FROM compliance_items WHERE status = 'active' `
) ;
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 } ;
}
// ---------------------------------------------------------------------------
// Write a parsed upload to the DB (within a transaction)
// ---------------------------------------------------------------------------
2026-05-06 11:44:17 -06:00
async function persistUpload ( { items , summary , reportDate , filename , userId } ) {
const { rows : activeRows } = await pool . query (
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
` SELECT id, hostname, metric_id, seen_count, first_seen_upload_id FROM compliance_items WHERE status = 'active' `
) ;
const activeMap = { } ;
activeRows . forEach ( r => { activeMap [ ` ${ r . hostname } ||| ${ r . metric _id } ` ] = r ; } ) ;
const newKeys = new Set ( items . map ( i => ` ${ i . hostname } ||| ${ i . metric _id } ` ) ) ;
2026-05-06 11:44:17 -06:00
const client = await pool . connect ( ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
try {
2026-05-06 11:44:17 -06:00
await client . query ( 'BEGIN' ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
// 1. Insert the upload record
2026-05-06 11:44:17 -06:00
const uploadResult = await client . query (
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
` INSERT INTO compliance_uploads (filename, report_date, uploaded_by, uploaded_at, summary_json)
2026-05-06 11:44:17 -06:00
VALUES ( $1 , $2 , $3 , NOW ( ) , $4 )
RETURNING id ` ,
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
[ filename , reportDate || null , userId || null , JSON . stringify ( summary ) ]
) ;
2026-05-06 11:44:17 -06:00
const uploadId = uploadResult . rows [ 0 ] . id ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
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 ) {
2026-05-06 11:44:17 -06:00
await client . query (
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
` UPDATE compliance_items
2026-05-06 11:44:17 -06:00
SET upload _id = $1 , seen _count = $2 , ip _address = $3 , device _type = $4 , extra _json = $5
WHERE id = $6 ` ,
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
[ uploadId , existing . seen _count + 1 , item . ip _address , item . device _type , extraStr , existing . id ]
) ;
recurringCount ++ ;
} else {
2026-05-06 11:44:17 -06:00
await client . query (
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
` 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 )
2026-05-06 11:44:17 -06:00
VALUES ( $1 , $2 , $3 , $4 , $5 , $6 , $7 , $8 , $9 , 'active' , $10 , 1 ) ` ,
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
[ uploadId , item . hostname , item . ip _address , item . device _type , item . team ,
item . metric _id , item . metric _desc , item . category , extraStr , uploadId ]
) ;
newCount ++ ;
}
}
// 3. Mark items not present in this upload as resolved
for ( const [ key , row ] of Object . entries ( activeMap ) ) {
if ( ! newKeys . has ( key ) ) {
2026-05-06 11:44:17 -06:00
await client . query (
` UPDATE compliance_items SET status = 'resolved', resolved_upload_id = $ 1 WHERE id = $ 2 ` ,
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
[ uploadId , row . id ]
) ;
resolvedCount ++ ;
}
}
// 4. Update upload with final counts
2026-05-06 11:44:17 -06:00
await client . query (
` UPDATE compliance_uploads SET new_count = $ 1, resolved_count = $ 2, recurring_count = $ 3 WHERE id = $ 4 ` ,
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
[ newCount , resolvedCount , recurringCount , uploadId ]
) ;
2026-05-06 11:44:17 -06:00
await client . query ( 'COMMIT' ) ;
2026-05-11 15:48:10 -06:00
// Task 7: Create/update compliance_snapshots for the current month
try {
const currentMonth = new Date ( ) . toISOString ( ) . slice ( 0 , 7 ) ; // YYYY-MM
// Compute per-vertical compliance percentages from current state
const { rows : verticalStats } = await pool . query (
` SELECT team AS vertical,
COUNT ( DISTINCT hostname ) : : int AS total _devices ,
COUNT ( DISTINCT CASE WHEN status = 'resolved' THEN hostname END ) : : int AS compliant ,
COUNT ( DISTINCT CASE WHEN status = 'active' THEN hostname END ) : : int AS non _compliant
FROM compliance _items
WHERE team IS NOT NULL
GROUP BY team `
) ;
for ( const vs of verticalStats ) {
const total = vs . total _devices ;
const compPct = total > 0 ? Math . round ( ( vs . compliant / total ) * 100 * 100 ) / 100 : 0 ;
await pool . 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 , vs . vertical , total , vs . compliant , vs . non _compliant , compPct ]
) ;
}
} catch ( snapshotErr ) {
// Snapshot creation is non-critical — log but don't fail the upload
console . error ( '[Compliance] Snapshot creation error:' , snapshotErr . message ) ;
}
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
return { uploadId , newCount , recurringCount , resolvedCount } ;
} catch ( err ) {
2026-05-06 11:44:17 -06:00
await client . query ( 'ROLLBACK' ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
throw err ;
2026-05-06 11:44:17 -06:00
} finally {
client . release ( ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
}
}
// ---------------------------------------------------------------------------
// Group flat compliance_items rows into per-device objects
// ---------------------------------------------------------------------------
function groupByHostname ( rows , noteHostnames ) {
const deviceMap = { } ;
for ( const row of rows ) {
if ( ! deviceMap [ row . hostname ] ) {
deviceMap [ row . hostname ] = {
2026-05-06 11:44:17 -06:00
hostname : row . hostname , ip _address : row . ip _address || '' , device _type : row . device _type || '' ,
team : row . team || '' , status : row . status , failing _metrics : [ ] ,
seen _count : row . seen _count || 1 , first _seen : row . first _seen || null ,
last _seen : row . last _seen || null , resolved _on : row . resolved _on || null ,
has _notes : noteHostnames . has ( row . hostname ) ,
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
} ;
}
const dev = deviceMap [ row . hostname ] ;
2026-05-06 11:44:17 -06:00
dev . failing _metrics . push ( { metric _id : row . metric _id , metric _desc : row . metric _desc || '' , category : row . category || '' } ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
if ( ( row . seen _count || 1 ) > dev . seen _count ) dev . seen _count = row . seen _count ;
2026-05-06 11:44:17 -06:00
if ( row . first _seen && ( ! dev . first _seen || row . first _seen < dev . first _seen ) ) dev . first _seen = row . first _seen ;
if ( row . last _seen && ( ! dev . last _seen || row . last _seen > dev . last _seen ) ) dev . last _seen = row . last _seen ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
}
return Object . values ( deviceMap ) ;
}
2026-05-01 17:15:41 +00:00
// ---------------------------------------------------------------------------
2026-05-06 11:44:17 -06:00
// Pure helpers
2026-05-01 17:15:41 +00:00
// ---------------------------------------------------------------------------
const BUCKET _ORDER = [ '1 cycle' , '2– 3 cycles' , '4– 6 cycles' , '7+ cycles' ] ;
function bucketAgingItems ( items ) {
const teams = [ 'STEAM' , 'ACCESS-ENG' , 'ACCESS-OPS' , 'INTELDEV' ] ;
const buckets = { } ;
for ( const b of BUCKET _ORDER ) {
buckets [ b ] = { bucket : b , total : 0 } ;
for ( const t of teams ) buckets [ b ] [ t ] = 0 ;
}
for ( const item of items ) {
const sc = item . seen _count ;
let label ;
2026-05-06 11:44:17 -06:00
if ( sc === 1 ) label = '1 cycle' ;
else if ( sc >= 2 && sc <= 3 ) label = '2– 3 cycles' ;
else if ( sc >= 4 && sc <= 6 ) label = '4– 6 cycles' ;
else label = '7+ cycles' ;
2026-05-01 17:15:41 +00:00
buckets [ label ] . total += 1 ;
2026-05-06 11:44:17 -06:00
if ( item . team in buckets [ label ] ) buckets [ label ] [ item . team ] += 1 ;
2026-05-01 17:15:41 +00:00
}
return BUCKET _ORDER . map ( b => buckets [ b ] ) ;
}
function computeWaterfall ( uploads ) {
let start = 0 ;
return uploads . map ( ( row ) => {
const end = start + row . new _count + row . recurring _count - row . resolved _count ;
2026-05-06 11:44:17 -06:00
const entry = { date : row . report _date , start , new _count : row . new _count , recurring _count : row . recurring _count , resolved _count : row . resolved _count , end } ;
2026-05-01 17:15:41 +00:00
start = end ;
return entry ;
} ) ;
}
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
// ---------------------------------------------------------------------------
// Router factory
// ---------------------------------------------------------------------------
2026-05-06 11:44:17 -06:00
function createComplianceRouter ( upload ) {
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
const router = express . Router ( ) ;
// All compliance routes require authentication
2026-05-06 11:44:17 -06:00
router . use ( requireAuth ( ) ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
2026-05-13 07:57:41 -06:00
/ * *
* POST / preview
* Uploads an xlsx file , parses it , computes a diff against current active items ,
* performs schema drift detection , and stores parsed data in a temp file for later commit .
*
* @ body multipart / form - data — field "file" ( xlsx spreadsheet , max 10 MB )
* @ response 200 { drift , drift _error , schema , diff : { new _count , recurring _count , resolved _count } , tempFile , filename , report _date , total _items }
* @ response 400 { error } — no file , wrong extension , or upload error
* @ response 422 { error } — parser returned an error
* @ response 500 { error } — config load failure or parse failure
* /
2026-04-06 16:18:07 -06:00
router . post ( '/preview' , requireGroup ( 'Admin' , 'Standard_User' ) , ( req , res ) => {
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
upload . single ( 'file' ) ( req , res , async ( uploadErr ) => {
2026-05-06 11:44:17 -06:00
if ( uploadErr ) return res . status ( 400 ) . json ( { error : uploadErr . message } ) ;
if ( ! req . file ) return res . status ( 400 ) . json ( { error : 'No file uploaded' } ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
if ( path . extname ( req . file . originalname ) . toLowerCase ( ) !== '.xlsx' ) {
fs . unlink ( req . file . path , ( ) => { } ) ;
return res . status ( 400 ) . json ( { error : 'File must be an .xlsx spreadsheet' } ) ;
}
try {
2026-05-06 11:44:17 -06:00
let drift = null , drift _error = null ;
2026-04-20 20:12:12 +00:00
let config ;
2026-05-06 11:44:17 -06:00
try { config = loadConfig ( CONFIG _PATH ) ; } catch ( configErr ) {
2026-04-20 20:12:12 +00:00
fs . unlink ( req . file . path , ( ) => { } ) ;
return res . status ( 500 ) . json ( { error : 'Configuration file could not be loaded: ' + configErr . message } ) ;
}
let xlsxSchema = null ;
try {
xlsxSchema = await extractXlsxSchema ( req . file . path ) ;
2026-05-06 11:44:17 -06:00
if ( xlsxSchema . error ) throw new Error ( xlsxSchema . error ) ;
2026-04-20 20:12:12 +00:00
drift = compareSchemaToDrift ( xlsxSchema , config ) ;
} catch ( driftErr ) {
drift = null ;
drift _error = driftErr . message || 'Drift check failed' ;
}
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
const parsed = await parseXlsx ( req . file . path ) ;
if ( parsed . error ) {
fs . unlink ( req . file . path , ( ) => { } ) ;
return res . status ( 422 ) . json ( { error : parsed . error } ) ;
}
2026-05-06 11:44:17 -06:00
const diff = await computeDiff ( parsed . items ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
if ( ! fs . existsSync ( TEMP _DIR ) ) fs . mkdirSync ( TEMP _DIR , { recursive : true } ) ;
const tempFilename = ` compliance_preview_ ${ Date . now ( ) } _ ${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } .json ` ;
const tempFilePath = path . join ( TEMP _DIR , tempFilename ) ;
fs . writeFileSync ( tempFilePath , JSON . stringify ( {
2026-05-06 11:44:17 -06:00
items : parsed . items , summary : parsed . summary ,
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
report _date : parsed . report _date ,
2026-05-06 11:44:17 -06:00
filename : req . file . originalname . replace ( /[^\w.\-() ]/g , '_' ) ,
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
} ) ) ;
fs . unlink ( req . file . path , ( ) => { } ) ;
res . json ( {
2026-05-06 11:44:17 -06:00
drift , drift _error , schema : xlsxSchema ,
diff : { new _count : diff . newCount , recurring _count : diff . recurringCount , resolved _count : diff . resolvedCount } ,
tempFile : tempFilePath , filename : req . file . originalname ,
report _date : parsed . report _date , total _items : parsed . total ,
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
} ) ;
} catch ( err ) {
fs . unlink ( req . file . path , ( ) => { } ) ;
console . error ( '[Compliance] Preview error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to parse file: ' + err . message } ) ;
}
} ) ;
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* POST / reconcile - config
* Applies schema drift reconciliation to the compliance config file based on detected drift findings .
*
* @ body { drift : object , schema ? : object }
* @ response 200 { changes : Array < { action , key , value , detail } > , message }
* @ response 400 { error } — missing drift or no findings to reconcile
* @ response 500 { error } — reconciliation failure
* /
2026-04-20 20:12:12 +00:00
router . post ( '/reconcile-config' , requireGroup ( 'Admin' ) , async ( req , res ) => {
const { drift , schema } = req . body ;
2026-05-06 11:44:17 -06:00
if ( ! drift || typeof drift !== 'object' ) return res . status ( 400 ) . json ( { error : 'drift report is required in request body' } ) ;
const hasFindings = ( drift . breaking && drift . breaking . length > 0 ) || ( drift . silent _miss && drift . silent _miss . length > 0 ) ;
if ( ! hasFindings ) return res . status ( 400 ) . json ( { error : 'No breaking or silent-miss findings to reconcile' } ) ;
2026-04-20 20:12:12 +00:00
try {
const { changes } = reconcileConfig ( CONFIG _PATH , drift , schema || null ) ;
2026-05-06 11:44:17 -06:00
if ( changes . length === 0 ) return res . json ( { changes : [ ] , message : 'No changes needed' } ) ;
2026-04-20 20:12:12 +00:00
for ( const change of changes ) {
2026-05-06 11:44:17 -06:00
logAudit ( { userId : req . user . id , username : req . user . username , action : 'compliance_config_reconcile' , entityType : 'compliance_config' , entityId : change . value , details : { action : change . action , key : change . key , detail : change . detail } , ipAddress : req . ip } ) ;
2026-04-20 20:12:12 +00:00
}
res . json ( { changes , message : ` Reconciled ${ changes . length } config change(s) ` } ) ;
} catch ( err ) {
console . error ( '[Compliance] Reconcile config error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to reconcile config: ' + err . message } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* POST / commit
* Commits a previously previewed compliance upload to the database . Resolves items no longer
* present , upserts recurring / new items , and creates a compliance snapshot for the current month .
*
* @ body { tempFile : string , filename ? : string , report _date ? : string }
* @ response 200 { upload : { id , filename , report _date , uploaded _at , new _count , resolved _count , recurring _count } }
* @ response 400 { error } — missing / invalid tempFile or expired preview session
* @ response 500 { error } — commit failure
* /
2026-04-06 16:18:07 -06:00
router . post ( '/commit' , requireGroup ( 'Admin' , 'Standard_User' ) , async ( req , res ) => {
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
const { tempFile , filename , report _date } = req . body ;
2026-05-06 11:44:17 -06:00
if ( ! tempFile || typeof tempFile !== 'string' ) return res . status ( 400 ) . json ( { error : 'tempFile is required' } ) ;
if ( ! isSafeTempPath ( tempFile ) ) return res . status ( 400 ) . json ( { error : 'Invalid tempFile path' } ) ;
if ( ! fs . existsSync ( tempFile ) ) return res . status ( 400 ) . json ( { error : 'Preview session expired — please upload again' } ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
let parsed ;
2026-05-06 11:44:17 -06:00
try { parsed = JSON . parse ( fs . readFileSync ( tempFile , 'utf8' ) ) ; }
catch { return res . status ( 400 ) . json ( { error : 'Could not read preview data — please upload again' } ) ; }
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
try {
2026-05-06 11:44:17 -06:00
const result = await persistUpload ( {
items : parsed . items , summary : parsed . summary ,
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
reportDate : report _date || parsed . report _date ,
2026-05-06 11:44:17 -06:00
filename : filename || parsed . filename ,
userId : req . user ? . id || null ,
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
} ) ;
fs . unlink ( tempFile , ( ) => { } ) ;
2026-05-06 11:44:17 -06:00
const { rows } = await pool . query (
` SELECT id, filename, report_date, uploaded_at, new_count, resolved_count, recurring_count
FROM compliance _uploads WHERE id = $1 ` , [result.uploadId]
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
) ;
2026-05-06 11:44:17 -06:00
res . json ( { upload : rows [ 0 ] } ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
} catch ( err ) {
console . error ( '[Compliance] Commit error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to commit upload: ' + err . message } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* GET / uploads
* Returns all compliance upload records ordered by most recent first .
*
* @ response 200 { uploads : Array < { id , filename , report _date , uploaded _at , new _count , resolved _count , recurring _count } > }
* @ response 500 { error } — database error
* /
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
router . get ( '/uploads' , async ( req , res ) => {
try {
2026-05-06 11:44:17 -06:00
const { rows } = await pool . query (
` SELECT id, filename, report_date, uploaded_at, new_count, resolved_count, recurring_count
FROM compliance _uploads ORDER BY id DESC `
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
) ;
res . json ( { uploads : rows } ) ;
} catch ( err ) {
console . error ( '[Compliance] GET /uploads error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Database error' } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* POST / rollback / : uploadId
* Rolls back the most recent compliance upload — deletes new items introduced by that upload ,
* reactivates items it resolved , and removes the upload record .
*
* @ param uploadId — numeric ID of the upload to roll back ( must be the latest )
* @ response 200 { message , rolled _back : { upload _id , filename , report _date , items _deleted , items _reactivated } }
* @ response 400 { error } — invalid ID or not the latest upload
* @ response 404 { error } — upload not found
* @ response 500 { error } — rollback failure
* /
2026-04-20 20:12:12 +00:00
router . post ( '/rollback/:uploadId' , requireGroup ( 'Admin' ) , async ( req , res ) => {
const uploadId = parseInt ( req . params . uploadId , 10 ) ;
2026-05-06 11:44:17 -06:00
if ( isNaN ( uploadId ) ) return res . status ( 400 ) . json ( { error : 'Invalid upload ID' } ) ;
2026-04-20 20:12:12 +00:00
try {
2026-05-06 11:44:17 -06:00
const { rows : uploadRows } = await pool . query (
` SELECT id, filename, report_date, new_count, resolved_count, recurring_count FROM compliance_uploads WHERE id = $ 1 ` , [ uploadId ]
2026-04-20 20:12:12 +00:00
) ;
2026-05-06 11:44:17 -06:00
const upload = uploadRows [ 0 ] ;
if ( ! upload ) return res . status ( 404 ) . json ( { error : 'Upload not found' } ) ;
2026-04-20 20:12:12 +00:00
2026-05-06 11:44:17 -06:00
const { rows : latestRows } = await pool . query ( ` SELECT id FROM compliance_uploads ORDER BY id DESC LIMIT 1 ` ) ;
if ( latestRows [ 0 ] . id !== uploadId ) {
return res . status ( 400 ) . json ( { error : 'Only the most recent upload can be rolled back' , latest _upload _id : latestRows [ 0 ] . id } ) ;
2026-04-20 20:12:12 +00:00
}
2026-05-06 11:44:17 -06:00
const { rows : prevRows } = await pool . query ( ` SELECT id FROM compliance_uploads WHERE id < $ 1 ORDER BY id DESC LIMIT 1 ` , [ uploadId ] ) ;
const previousUpload = prevRows [ 0 ] ;
2026-04-20 20:12:12 +00:00
2026-05-06 11:44:17 -06:00
const client = await pool . connect ( ) ;
2026-04-20 20:12:12 +00:00
try {
2026-05-06 11:44:17 -06:00
await client . query ( 'BEGIN' ) ;
2026-04-20 20:12:12 +00:00
2026-05-06 11:44:17 -06:00
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 ]
2026-04-20 20:12:12 +00:00
) ;
if ( previousUpload ) {
2026-05-06 11:44:17 -06:00
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 ` ,
[ previousUpload . id , uploadId ]
2026-04-20 20:12:12 +00:00
) ;
}
2026-05-06 11:44:17 -06:00
await client . query ( ` DELETE FROM compliance_uploads WHERE id = $ 1 ` , [ uploadId ] ) ;
await client . query ( 'COMMIT' ) ;
2026-04-20 20:12:12 +00:00
2026-05-06 11:44:17 -06:00
logAudit ( { userId : req . user . id , username : req . user . username , action : 'compliance_upload_rollback' , entityType : 'compliance_upload' , entityId : String ( uploadId ) , details : { filename : upload . filename , report _date : upload . report _date , items _deleted : deleteNew . rowCount , items _reactivated : reactivate . rowCount } , ipAddress : req . ip } ) ;
2026-04-20 20:12:12 +00:00
2026-05-06 11:44:17 -06:00
res . json ( { message : ` Rolled back upload " ${ upload . filename } " ` , rolled _back : { upload _id : uploadId , filename : upload . filename , report _date : upload . report _date , items _deleted : deleteNew . rowCount , items _reactivated : reactivate . rowCount } } ) ;
2026-04-20 20:12:12 +00:00
} catch ( err ) {
2026-05-06 11:44:17 -06:00
await client . query ( 'ROLLBACK' ) ;
2026-04-20 20:12:12 +00:00
throw err ;
2026-05-06 11:44:17 -06:00
} finally {
client . release ( ) ;
2026-04-20 20:12:12 +00:00
}
} catch ( err ) {
console . error ( '[Compliance] Rollback error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to rollback upload: ' + err . message } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* GET / summary
* Returns the summary data from the most recent compliance upload , optionally filtered by team .
*
* @ query team — optional , one of STEAM | ACCESS - ENG | ACCESS - OPS | INTELDEV
* @ response 200 { entries : Array , overall _scores : object , upload : { id , report _date , uploaded _at } | null }
* @ response 400 { error } — invalid team
* @ response 500 { error } — database error
* /
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
router . get ( '/summary' , async ( req , res ) => {
const team = req . query . team ;
2026-05-06 11:44:17 -06:00
if ( team && ! ALLOWED _TEAMS . has ( team ) ) return res . status ( 400 ) . json ( { error : 'Invalid team' } ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
try {
2026-05-14 15:39:25 -06:00
// Try AEO uploads first (vertical IS NULL), fall back to NTS_AEO multi-vertical upload
let { rows : latestRows } = await pool . query (
` SELECT id, summary_json, report_date, uploaded_at FROM compliance_uploads WHERE vertical IS NULL ORDER BY id DESC LIMIT 1 `
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
) ;
2026-05-14 15:39:25 -06:00
if ( latestRows . length === 0 || ! latestRows [ 0 ] . summary _json ) {
( { rows : latestRows } = await pool . query (
` SELECT id, summary_json, report_date, uploaded_at FROM compliance_uploads WHERE vertical = 'NTS_AEO' ORDER BY id DESC LIMIT 1 `
) ) ;
}
2026-05-06 11:44:17 -06:00
const latestUpload = latestRows [ 0 ] ;
if ( ! latestUpload || ! latestUpload . summary _json ) return res . json ( { entries : [ ] , overall _scores : { } , upload : null } ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
let summary ;
2026-05-06 11:44:17 -06:00
try { summary = JSON . parse ( latestUpload . summary _json ) ; } catch { return res . json ( { entries : [ ] , overall _scores : { } , upload : null } ) ; }
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
let entries = summary . entries || [ ] ;
2026-05-06 11:44:17 -06:00
if ( team ) entries = entries . filter ( e => e . team === team ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
2026-05-06 11:44:17 -06:00
res . json ( { entries , overall _scores : summary . overall _scores || { } , upload : { id : latestUpload . id , report _date : latestUpload . report _date , uploaded _at : latestUpload . uploaded _at } } ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
} catch ( err ) {
console . error ( '[Compliance] GET /summary error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Database error' } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* GET / items
* Returns compliance items grouped by hostname for a given team and status .
*
* @ query team — required , one of STEAM | ACCESS - ENG | ACCESS - OPS | INTELDEV
* @ query status — optional , "active" ( default ) or "resolved"
* @ response 200 { devices : Array < { hostname , ip _address , device _type , team , status , failing _metrics , seen _count , first _seen , last _seen , resolved _on , has _notes } > , team , status }
* @ response 400 { error } — missing / invalid team or invalid status
* @ response 500 { error } — database error
* /
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
router . get ( '/items' , async ( req , res ) => {
const { team , status = 'active' } = req . query ;
if ( ! team ) return res . status ( 400 ) . json ( { error : 'team is required' } ) ;
if ( ! ALLOWED _TEAMS . has ( team ) ) return res . status ( 400 ) . json ( { error : 'Invalid team' } ) ;
if ( ! [ 'active' , 'resolved' ] . includes ( status ) ) return res . status ( 400 ) . json ( { error : 'Invalid status' } ) ;
try {
2026-05-14 15:39:25 -06:00
// Include items from both AEO uploads (vertical IS NULL) and NTS_AEO multi-vertical uploads
2026-05-06 11:44:17 -06:00
const { rows } = await pool . query (
` SELECT ci.hostname, ci.ip_address, ci.device_type, ci.team, ci.metric_id, ci.metric_desc, ci.category, ci.status, ci.seen_count,
fu . report _date AS first _seen , lu . report _date AS last _seen , ru . report _date AS resolved _on
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
FROM compliance _items ci
LEFT JOIN compliance _uploads fu ON ci . first _seen _upload _id = fu . id
2026-05-06 11:44:17 -06:00
LEFT JOIN compliance _uploads lu ON ci . upload _id = lu . id
LEFT JOIN compliance _uploads ru ON ci . resolved _upload _id = ru . id
2026-05-14 15:39:25 -06:00
WHERE ci . team = $1 AND ci . status = $2 AND ( ci . vertical IS NULL OR ci . vertical = 'NTS_AEO' )
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
ORDER BY ci . hostname , ci . metric _id ` ,
[ team , status ]
) ;
2026-05-06 11:44:17 -06:00
const { rows : noteRows } = await pool . query ( ` SELECT DISTINCT hostname FROM compliance_notes ` ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
const noteHostnames = new Set ( noteRows . map ( r => r . hostname ) ) ;
const devices = groupByHostname ( rows , noteHostnames ) ;
res . json ( { devices , team , status } ) ;
} catch ( err ) {
console . error ( '[Compliance] GET /items error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Database error' } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* GET / items / : hostname
* Returns detailed information for a single device including all metrics and notes .
*
* @ param hostname — the device hostname
* @ response 200 { hostname , ip _address , device _type , team , metrics : Array < { metric _id , metric _desc , category , status , ... } > , notes : Array < { id , metric _id , note , group _id , created _at , created _by } > }
* @ response 400 { error } — invalid hostname
* @ response 404 { error } — device not found
* @ response 500 { error } — database error
* /
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
router . get ( '/items/:hostname' , async ( req , res ) => {
const hostname = req . params . hostname ;
2026-05-06 11:44:17 -06:00
if ( ! hostname || hostname . length > 300 ) return res . status ( 400 ) . json ( { error : 'Invalid hostname' } ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
try {
2026-05-06 11:44:17 -06:00
const { rows : metricRows } = await pool . query (
` SELECT ci.metric_id, ci.metric_desc, ci.category, ci.status, ci.ip_address, ci.device_type, ci.team, ci.seen_count, ci.extra_json,
2026-05-13 07:57:41 -06:00
ci . resolution _date , ci . remediation _plan ,
2026-05-06 11:44:17 -06:00
fu . report _date AS first _seen , fu . uploaded _at AS first _seen _at , lu . report _date AS last _seen , lu . uploaded _at AS last _seen _at , ru . report _date AS resolved _on
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
FROM compliance _items ci
LEFT JOIN compliance _uploads fu ON ci . first _seen _upload _id = fu . id
2026-05-06 11:44:17 -06:00
LEFT JOIN compliance _uploads lu ON ci . upload _id = lu . id
LEFT JOIN compliance _uploads ru ON ci . resolved _upload _id = ru . id
WHERE ci . hostname = $1
ORDER BY ci . status DESC , ci . metric _id ` , [hostname]
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
) ;
2026-05-06 11:44:17 -06:00
if ( metricRows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Device not found' } ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
2026-05-06 11:44:17 -06:00
const metrics = metricRows . map ( r => ( { ... r , extra : ( ( ) => { try { return JSON . parse ( r . extra _json || '{}' ) ; } catch { return { } ; } } ) ( ) , extra _json : undefined } ) ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
2026-05-06 11:44:17 -06:00
const { rows : notes } = await pool . query (
` SELECT cn.id, cn.metric_id, cn.note, cn.group_id, cn.created_at, u.username AS created_by
FROM compliance _notes cn LEFT JOIN users u ON cn . created _by = u . id
WHERE cn . hostname = $1 ORDER BY cn . created _at DESC ` , [hostname]
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
) ;
2026-05-15 10:53:14 -06:00
// Fetch remediation history
let history = [ ] ;
try {
const { rows : historyRows } = await pool . query (
` SELECT id, field_name, old_value, new_value, change_reason, changed_by, changed_at
FROM compliance _item _history WHERE hostname = $1 ORDER BY changed _at DESC LIMIT 10 ` ,
[ hostname ]
) ;
history = historyRows ;
} catch ( histErr ) {
console . error ( '[Compliance] History fetch error:' , histErr . message ) ;
}
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
const identity = metricRows . find ( r => r . status === 'active' ) || metricRows [ 0 ] ;
2026-05-13 07:57:41 -06:00
// Return resolution_date and remediation_plan from the first active item (or any item)
const resDate = identity . resolution _date ? ( typeof identity . resolution _date === 'string' ? identity . resolution _date : identity . resolution _date . toISOString ( ) . slice ( 0 , 10 ) ) : null ;
2026-05-15 10:53:14 -06:00
res . json ( { hostname , ip _address : identity . ip _address || '' , device _type : identity . device _type || '' , team : identity . team || '' , resolution _date : resDate , remediation _plan : identity . remediation _plan || '' , metrics , notes , history } ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
} catch ( err ) {
console . error ( '[Compliance] GET /items/:hostname error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Database error' } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* POST / notes
* Creates one or more compliance notes for a device , linked to specific metric IDs .
* All notes in a single call share a group _id for batch operations .
*
* @ body { hostname : string , metric _id ? : string , metric _ids ? : string [ ] , note : string }
* @ response 201 { notes : Array < { id , hostname , metric _id , note , group _id , created _at , created _by } > }
* @ response 400 { error } — invalid hostname , missing / invalid metric _id ( s ) , or empty note
* @ response 500 { error } — save failure
* /
2026-04-07 09:52:26 -06:00
router . post ( '/notes' , requireGroup ( 'Admin' , 'Standard_User' ) , async ( req , res ) => {
2026-04-16 14:28:44 -06:00
const { hostname , metric _id , metric _ids , note } = req . body ;
2026-05-06 11:44:17 -06:00
if ( ! hostname || typeof hostname !== 'string' || hostname . length > 300 || ! /^[a-zA-Z0-9._-]+$/ . test ( hostname ) ) return res . status ( 400 ) . json ( { error : 'Invalid hostname format' } ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
2026-04-16 14:28:44 -06:00
let resolvedIds ;
if ( metric _ids !== undefined ) {
2026-05-06 11:44:17 -06:00
if ( ! Array . isArray ( metric _ids ) ) return res . status ( 400 ) . json ( { error : 'metric_ids must be an array' } ) ;
2026-04-16 14:28:44 -06:00
resolvedIds = metric _ids ;
} else if ( metric _id !== undefined && metric _id !== null && metric _id !== '' ) {
2026-05-06 11:44:17 -06:00
if ( typeof metric _id !== 'string' || metric _id . length > 50 ) return res . status ( 400 ) . json ( { error : 'Invalid metric_id' } ) ;
2026-04-16 14:28:44 -06:00
resolvedIds = [ metric _id ] ;
} else {
return res . status ( 400 ) . json ( { error : 'metric_id or metric_ids is required' } ) ;
}
2026-05-06 11:44:17 -06:00
if ( resolvedIds . length === 0 ) return res . status ( 400 ) . json ( { error : 'At least one metric ID is required' } ) ;
2026-04-16 14:28:44 -06:00
for ( let i = 0 ; i < resolvedIds . length ; i ++ ) {
const mid = resolvedIds [ i ] ;
2026-05-06 11:44:17 -06:00
if ( ! mid || typeof mid !== 'string' || mid . length === 0 || mid . length > 50 ) return res . status ( 400 ) . json ( { error : ` Invalid metric_id at index ${ i } ` } ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
}
2026-04-16 14:28:44 -06:00
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
const noteText = String ( note || '' ) . trim ( ) . slice ( 0 , 1000 ) ;
2026-05-06 11:44:17 -06:00
if ( ! noteText ) return res . status ( 400 ) . json ( { error : 'Note cannot be empty' } ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
2026-04-16 14:28:44 -06:00
const groupId = crypto . randomUUID ( ) ;
const userId = req . user ? . id || null ;
2026-05-06 11:44:17 -06:00
const client = await pool . connect ( ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
try {
2026-05-06 11:44:17 -06:00
await client . query ( 'BEGIN' ) ;
2026-04-16 14:28:44 -06:00
const insertedIds = [ ] ;
for ( const mid of resolvedIds ) {
2026-05-06 11:44:17 -06:00
const { rows } = await client . query (
` INSERT INTO compliance_notes (hostname, metric_id, note, group_id, created_by, created_at) VALUES ( $ 1, $ 2, $ 3, $ 4, $ 5, NOW()) RETURNING id ` ,
2026-04-16 14:28:44 -06:00
[ hostname , mid , noteText , groupId , userId ]
) ;
2026-05-06 11:44:17 -06:00
insertedIds . push ( rows [ 0 ] . id ) ;
2026-04-16 14:28:44 -06:00
}
2026-05-06 11:44:17 -06:00
await client . query ( 'COMMIT' ) ;
2026-04-16 14:28:44 -06:00
2026-05-06 11:44:17 -06:00
const { rows : notes } = await pool . query (
` SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.group_id, cn.created_at, u.username AS created_by
FROM compliance _notes cn LEFT JOIN users u ON cn . created _by = u . id
WHERE cn . id = ANY ( $1 ) ORDER BY cn . id ASC ` , [insertedIds]
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
) ;
2026-04-16 14:28:44 -06:00
res . status ( 201 ) . json ( { notes } ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
} catch ( err ) {
2026-05-06 11:44:17 -06:00
await client . query ( 'ROLLBACK' ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
console . error ( '[Compliance] POST /notes error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to save note' } ) ;
2026-05-06 11:44:17 -06:00
} finally {
client . release ( ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* GET / notes / : hostname / : metricId
* Returns all notes for a specific device and metric combination .
*
* @ param hostname — the device hostname
* @ param metricId — the metric identifier
* @ response 200 { notes : Array < { id , note , created _at , created _by } > }
* @ response 400 { error } — invalid hostname or metricId
* @ response 500 { error } — database error
* /
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
router . get ( '/notes/:hostname/:metricId' , async ( req , res ) => {
const { hostname , metricId } = req . params ;
if ( ! hostname || hostname . length > 300 ) return res . status ( 400 ) . json ( { error : 'Invalid hostname' } ) ;
2026-05-06 11:44:17 -06:00
if ( ! metricId || metricId . length > 50 ) return res . status ( 400 ) . json ( { error : 'Invalid metricId' } ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
try {
2026-05-06 11:44:17 -06:00
const { rows : notes } = await pool . query (
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
` SELECT cn.id, cn.note, cn.created_at, u.username AS created_by
2026-05-06 11:44:17 -06:00
FROM compliance _notes cn LEFT JOIN users u ON cn . created _by = u . id
WHERE cn . hostname = $1 AND cn . metric _id = $2 ORDER BY cn . created _at DESC ` , [hostname, metricId]
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
) ;
res . json ( { notes } ) ;
} catch ( err ) {
console . error ( '[Compliance] GET /notes error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Database error' } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* DELETE / notes / : id
* Deletes a compliance note by ID . Only the note author or an Admin can delete .
* Optionally deletes all notes in the same group when ? group = true .
*
* @ param id — numeric note ID
* @ query group — optional , "true" to delete all notes sharing the same group _id
* @ response 200 { deleted : number }
* @ response 400 { error } — invalid note ID
* @ response 403 { error } — not the author and not Admin
* @ response 404 { error } — note not found
* @ response 500 { error } — delete failure
* /
2026-04-20 21:39:43 +00:00
router . delete ( '/notes/:id' , requireGroup ( 'Admin' , 'Standard_User' ) , async ( req , res ) => {
const noteId = parseInt ( req . params . id , 10 ) ;
if ( isNaN ( noteId ) ) return res . status ( 400 ) . json ( { error : 'Invalid note ID' } ) ;
const deleteGroup = req . query . group === 'true' ;
try {
2026-05-06 11:44:17 -06:00
const { rows } = await pool . query ( ` SELECT id, hostname, metric_id, note, group_id, created_by FROM compliance_notes WHERE id = $ 1 ` , [ noteId ] ) ;
const noteRow = rows [ 0 ] ;
if ( ! noteRow ) return res . status ( 404 ) . json ( { error : 'Note not found' } ) ;
2026-04-20 21:39:43 +00:00
2026-05-06 11:44:17 -06:00
const isAuthor = req . user && String ( req . user . id ) === String ( noteRow . created _by ) ;
2026-04-20 21:39:43 +00:00
const isAdminUser = req . user && req . user . group === 'Admin' ;
2026-05-06 11:44:17 -06:00
if ( ! isAuthor && ! isAdminUser ) return res . status ( 403 ) . json ( { error : 'You can only delete your own notes' } ) ;
2026-04-20 21:39:43 +00:00
let deleted = 0 ;
2026-05-06 11:44:17 -06:00
if ( deleteGroup && noteRow . group _id ) {
const result = await pool . query ( ` DELETE FROM compliance_notes WHERE group_id = $ 1 ` , [ noteRow . group _id ] ) ;
deleted = result . rowCount ;
2026-04-20 21:39:43 +00:00
} else {
2026-05-06 11:44:17 -06:00
const result = await pool . query ( ` DELETE FROM compliance_notes WHERE id = $ 1 ` , [ noteId ] ) ;
deleted = result . rowCount ;
2026-04-20 21:39:43 +00:00
}
2026-05-06 11:44:17 -06:00
logAudit ( { userId : req . user . id , username : req . user . username , action : 'compliance_note_delete' , entityType : 'compliance_note' , entityId : String ( noteId ) , details : JSON . stringify ( { hostname : noteRow . hostname , group _id : noteRow . group _id , deleted _count : deleted } ) , ipAddress : req . ip } ) ;
2026-04-20 21:39:43 +00:00
res . json ( { deleted } ) ;
} catch ( err ) {
console . error ( '[Compliance] DELETE /notes error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to delete note' } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* GET / trends
* Returns historical compliance upload trends with per - team breakdowns for charting .
*
* @ response 200 { trends : Array < { report _date , new _count , recurring _count , resolved _count , total _active , STEAM , ACCESS - ENG , ACCESS - OPS , INTELDEV } > }
* @ response 500 { error } — database error
* /
2026-04-02 09:49:32 -06:00
router . get ( '/trends' , async ( req , res ) => {
try {
2026-05-06 11:44:17 -06:00
const { rows : uploads } = await pool . query (
` SELECT id, report_date, COALESCE(new_count, 0) AS new_count, COALESCE(recurring_count, 0) AS recurring_count, COALESCE(resolved_count, 0) AS resolved_count, COALESCE(new_count, 0) + COALESCE(recurring_count, 0) AS total_active FROM compliance_uploads ORDER BY report_date ASC `
2026-04-02 09:49:32 -06:00
) ;
if ( uploads . length === 0 ) return res . json ( { trends : [ ] } ) ;
2026-05-06 11:44:17 -06:00
const { rows : teamRows } = await pool . query (
` SELECT ci.upload_id, ci.team, COUNT(ci.id)::int AS count FROM compliance_items ci WHERE ci.team IS NOT NULL GROUP BY ci.upload_id, ci.team `
2026-04-02 09:49:32 -06:00
) ;
const teamMap = { } ;
2026-05-06 11:44:17 -06:00
teamRows . forEach ( r => { if ( ! teamMap [ r . upload _id ] ) teamMap [ r . upload _id ] = { } ; teamMap [ r . upload _id ] [ r . team ] = r . count ; } ) ;
2026-04-02 09:49:32 -06:00
const trends = uploads . map ( u => ( {
2026-05-06 11:44:17 -06:00
report _date : u . report _date , new _count : u . new _count , recurring _count : u . recurring _count , resolved _count : u . resolved _count , total _active : u . total _active ,
STEAM : teamMap [ u . id ] ? . STEAM || 0 , 'ACCESS-ENG' : teamMap [ u . id ] ? . [ 'ACCESS-ENG' ] || 0 , 'ACCESS-OPS' : teamMap [ u . id ] ? . [ 'ACCESS-OPS' ] || 0 , INTELDEV : teamMap [ u . id ] ? . INTELDEV || 0 ,
2026-04-02 09:49:32 -06:00
} ) ) ;
res . json ( { trends } ) ;
} catch ( err ) {
console . error ( '[Compliance] GET /trends error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Database error' } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* GET / mttr
* Returns aging bucket distribution of active compliance items ( 1 cycle , 2 – 3 , 4 – 6 , 7 + ) with per - team counts .
*
* @ response 200 { aging : Array < { bucket , total , STEAM , ACCESS - ENG , ACCESS - OPS , INTELDEV } > }
* @ response 500 { error } — database error
* /
2026-04-02 09:49:32 -06:00
router . get ( '/mttr' , async ( req , res ) => {
try {
2026-05-06 11:44:17 -06:00
const { rows } = await pool . query ( ` SELECT COALESCE(seen_count, 1) AS seen_count, team FROM compliance_items WHERE status = 'active' ` ) ;
if ( rows . length === 0 ) return res . json ( { aging : [ ] } ) ;
2026-05-01 17:15:41 +00:00
const aging = bucketAgingItems ( rows ) ;
res . json ( { aging } ) ;
2026-04-02 09:49:32 -06:00
} catch ( err ) {
console . error ( '[Compliance] GET /mttr error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Database error' } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* GET / top - recurring
* Returns waterfall chart data computed from upload history ( start , new , recurring , resolved , end per upload ) .
*
* @ response 200 { waterfall : Array < { date , start , new _count , recurring _count , resolved _count , end } > }
* @ response 500 { error } — database error
* /
2026-04-02 09:49:32 -06:00
router . get ( '/top-recurring' , async ( req , res ) => {
try {
2026-05-06 11:44:17 -06:00
const { rows } = await pool . query (
` SELECT id, report_date, COALESCE(new_count, 0) AS new_count, COALESCE(recurring_count, 0) AS recurring_count, COALESCE(resolved_count, 0) AS resolved_count FROM compliance_uploads ORDER BY report_date ASC `
2026-04-02 09:49:32 -06:00
) ;
2026-05-01 17:15:41 +00:00
const waterfall = computeWaterfall ( rows ) ;
res . json ( { waterfall } ) ;
2026-04-02 09:49:32 -06:00
} catch ( err ) {
console . error ( '[Compliance] GET /top-recurring error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Database error' } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* GET / category - trend
* Returns per - upload category breakdown counts for trend charting .
*
* @ response 200 { categoryTrend : Array < { report _date , category , count } > }
* @ response 500 { error } — database error
* /
2026-04-02 09:49:32 -06:00
router . get ( '/category-trend' , async ( req , res ) => {
try {
2026-05-06 11:44:17 -06:00
const { rows } = await pool . query (
` SELECT cu.report_date, COALESCE(ci.category, 'Unknown') AS category, COUNT(ci.id)::int AS count
FROM compliance _uploads cu JOIN compliance _items ci ON ci . upload _id = cu . id
GROUP BY cu . id , cu . report _date , category ORDER BY cu . report _date ASC `
2026-04-02 09:49:32 -06:00
) ;
res . json ( { categoryTrend : rows } ) ;
} catch ( err ) {
console . error ( '[Compliance] GET /category-trend error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Database error' } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* PATCH / items / : hostname / metadata
* Updates resolution _date and / or remediation _plan for all active compliance items matching a hostname .
*
* @ param hostname — the device hostname
* @ body { resolution _date ? : string | null , remediation _plan ? : string | null }
* @ response 200 { updated : number }
* @ response 400 { error } — invalid hostname , invalid date format , plan exceeds 2000 chars , or no fields provided
* @ response 404 { error } — device not found
* @ response 500 { error } — update failure
* /
2026-05-11 15:48:10 -06:00
router . patch ( '/items/:hostname/metadata' , requireGroup ( 'Admin' , 'Standard_User' ) , async ( req , res ) => {
const hostname = req . params . hostname ;
if ( ! hostname || hostname . length > 300 ) return res . status ( 400 ) . json ( { error : 'Invalid hostname' } ) ;
2026-05-15 10:53:14 -06:00
const { resolution _date , remediation _plan , change _reason } = req . body ;
2026-05-11 15:48:10 -06:00
// Validate resolution_date: must be a valid ISO date string or null
if ( resolution _date !== undefined && resolution _date !== null ) {
if ( ! isValidDateString ( resolution _date ) ) {
return res . status ( 400 ) . json ( { error : 'Invalid resolution_date format' } ) ;
}
}
// Validate remediation_plan: must be <= 2000 chars or null
if ( remediation _plan !== undefined && remediation _plan !== null ) {
const planValidation = validateRemediationPlan ( remediation _plan ) ;
if ( ! planValidation . valid ) {
return res . status ( 400 ) . json ( { error : planValidation . error } ) ;
}
}
2026-05-15 10:53:14 -06:00
// Validate change_reason: optional, max 500 chars
if ( change _reason !== undefined && change _reason !== null && change _reason . length > 500 ) {
return res . status ( 400 ) . json ( { error : 'Change reason exceeds 500 characters' } ) ;
}
const setClauses = [ ] ;
const values = [ ] ;
let paramIdx = 1 ;
if ( resolution _date !== undefined ) {
setClauses . push ( ` resolution_date = $ ${ paramIdx ++ } ` ) ;
values . push ( resolution _date ) ;
}
if ( remediation _plan !== undefined ) {
setClauses . push ( ` remediation_plan = $ ${ paramIdx ++ } ` ) ;
values . push ( remediation _plan ) ;
}
if ( setClauses . length === 0 ) {
return res . status ( 400 ) . json ( { error : 'No fields to update' } ) ;
}
const client = await pool . connect ( ) ;
2026-05-11 15:48:10 -06:00
try {
2026-05-15 10:53:14 -06:00
await client . query ( 'BEGIN' ) ;
2026-05-11 15:48:10 -06:00
2026-05-15 10:53:14 -06:00
// Get current values before updating
const { rows : currentRows } = await client . query (
` SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
FROM compliance _items WHERE hostname = $1 AND status = 'active'
ORDER BY hostname , id DESC LIMIT 1 ` ,
[ hostname ]
) ;
if ( currentRows . length === 0 ) {
await client . query ( 'ROLLBACK' ) ;
client . release ( ) ;
return res . status ( 404 ) . json ( { error : 'Device not found' } ) ;
}
const current = currentRows [ 0 ] ;
const currentResDate = current . resolution _date
? ( typeof current . resolution _date === 'string' ? current . resolution _date : current . resolution _date . toISOString ( ) . slice ( 0 , 10 ) )
: null ;
const currentPlan = current . remediation _plan || null ;
const reasonText = change _reason && change _reason . trim ( ) ? change _reason . trim ( ) : null ;
// Insert history for each changed field
2026-05-11 15:48:10 -06:00
if ( resolution _date !== undefined ) {
2026-05-15 10:53:14 -06:00
const newVal = resolution _date || null ;
if ( currentResDate !== newVal ) {
await client . query (
` INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
VALUES ( $1 , 'resolution_date' , $2 , $3 , $4 , $5 ) ` ,
[ hostname , currentResDate , newVal , reasonText , req . user . username ]
) ;
}
2026-05-11 15:48:10 -06:00
}
if ( remediation _plan !== undefined ) {
2026-05-15 10:53:14 -06:00
const newVal = remediation _plan || null ;
if ( currentPlan !== newVal ) {
await client . query (
` INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
VALUES ( $1 , 'remediation_plan' , $2 , $3 , $4 , $5 ) ` ,
[ hostname , currentPlan , newVal , reasonText , req . user . username ]
) ;
}
2026-05-11 15:48:10 -06:00
}
2026-05-15 10:53:14 -06:00
// Update the items
2026-05-11 15:48:10 -06:00
values . push ( hostname ) ;
2026-05-15 10:53:14 -06:00
const result = await client . query (
2026-05-13 07:57:41 -06:00
` UPDATE compliance_items SET ${ setClauses . join ( ', ' ) } WHERE hostname = $ ${ paramIdx } ` ,
2026-05-11 15:48:10 -06:00
values
) ;
2026-05-15 10:53:14 -06:00
await client . query ( 'COMMIT' ) ;
2026-05-11 15:48:10 -06:00
logAudit ( {
userId : req . user . id ,
username : req . user . username ,
action : 'compliance_metadata_update' ,
entityType : 'compliance_item' ,
entityId : hostname ,
2026-05-15 10:53:14 -06:00
details : { resolution _date , remediation _plan , change _reason : reasonText } ,
2026-05-11 15:48:10 -06:00
ipAddress : req . ip ,
} ) ;
res . json ( { updated : result . rowCount } ) ;
} catch ( err ) {
2026-05-15 10:53:14 -06:00
await client . query ( 'ROLLBACK' ) ;
2026-05-11 15:48:10 -06:00
console . error ( '[Compliance] PATCH /items/:hostname/metadata error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to update device metadata' } ) ;
2026-05-15 10:53:14 -06:00
} finally {
client . release ( ) ;
2026-05-11 15:48:10 -06:00
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* GET / vcl / stats
* Returns VCL executive summary statistics including device counts , compliance percentage ,
* non - compliant asset categorization ( donut ) , heavy hitters by team , and vertical breakdown with burndown .
*
* @ response 200 { stats : { total _devices , in _scope , compliant , non _compliant , remediations _required , compliance _pct , target _pct } , donut : { blocked : { count , pct } , in _progress : { count , pct } } , heavy _hitters : Array < { vertical , team , non _compliant , compliance _date , notes } > , vertical _breakdown : Array < { vertical , compliance _pct , team , non _compliant , actual _burndown , forecast _burndown , blockers , risk _acceptances , notes } > }
* @ response 500 { error } — database error
* /
2026-05-11 15:48:10 -06:00
const VCL _TARGET _PCT = parseInt ( process . env . VCL _TARGET _PCT , 10 ) || 95 ;
router . get ( '/vcl/stats' , async ( req , res ) => {
try {
2026-05-13 07:57:41 -06:00
// Compute device-level stats using DISTINCT hostname
// A device is "compliant" if it has NO active findings
const { rows : statsRows } = await pool . query ( `
SELECT
COUNT ( DISTINCT hostname ) AS total _devices ,
COUNT ( DISTINCT hostname ) AS in _scope ,
COUNT ( DISTINCT CASE
WHEN hostname NOT IN ( SELECT DISTINCT hostname FROM compliance _items WHERE status = 'active' )
THEN hostname END ) AS compliant ,
COUNT ( DISTINCT CASE
WHEN hostname IN ( SELECT DISTINCT hostname FROM compliance _items WHERE status = 'active' )
THEN hostname END ) AS non _compliant
FROM compliance _items
` );
const raw = statsRows [ 0 ] || { } ;
const total _devices = parseInt ( raw . total _devices ) || 0 ;
const in _scope = parseInt ( raw . in _scope ) || 0 ;
const compliant = parseInt ( raw . compliant ) || 0 ;
const non _compliant = parseInt ( raw . non _compliant ) || 0 ;
const compliance _pct = in _scope > 0 ? Math . round ( ( compliant / in _scope ) * 100 ) : 0 ;
const stats = {
total _devices ,
in _scope ,
compliant ,
non _compliant ,
remediations _required : non _compliant ,
compliance _pct ,
target _pct : VCL _TARGET _PCT ,
} ;
2026-05-11 15:48:10 -06:00
2026-05-13 07:57:41 -06:00
// Donut: categorize non-compliant DEVICES by resolution_date presence
// A device is "blocked" if it has no resolution_date on any of its active findings
// A device is "in_progress" if at least one active finding has a resolution_date
const { rows : donutRows } = await pool . query ( `
SELECT
hostname ,
MAX ( resolution _date ) AS resolution _date
FROM compliance _items
WHERE status = 'active'
GROUP BY hostname
` );
const donut = categorizeNonCompliant ( donutRows ) ;
// Heavy hitters: group by team, count non-compliant DEVICES per team
const { rows : teamRows } = await pool . query ( `
SELECT
COALESCE ( team , 'Unknown' ) AS team ,
COUNT ( DISTINCT hostname ) AS non _compliant ,
MAX ( resolution _date ) AS compliance _date
FROM compliance _items
WHERE status = 'active'
GROUP BY team
ORDER BY COUNT ( DISTINCT hostname ) DESC
` );
const heavy _hitters = teamRows . map ( r => ( {
vertical : r . team ,
team : r . team ,
non _compliant : parseInt ( r . non _compliant ) ,
compliance _date : r . compliance _date ? r . compliance _date . toISOString ( ) . slice ( 0 , 10 ) : null ,
notes : '' ,
} ) ) ;
2026-05-11 15:48:10 -06:00
// Vertical breakdown with burndown
const verticalBreakdown = [ ] ;
2026-05-13 07:57:41 -06:00
for ( const teamRow of teamRows ) {
const team = teamRow . team ;
const teamNonCompliant = parseInt ( teamRow . non _compliant ) ;
// Get total devices for this team (all statuses)
const { rows : teamTotalRows } = await pool . query (
` SELECT COUNT(DISTINCT hostname) AS total FROM compliance_items WHERE COALESCE(team, 'Unknown') = $ 1 ` ,
[ team ]
) ;
const teamTotal = parseInt ( teamTotalRows [ 0 ] ? . total ) || 0 ;
const teamCompliant = teamTotal - teamNonCompliant ;
const compliance _pct _team = teamTotal > 0 ? Math . round ( ( teamCompliant / teamTotal ) * 100 ) : 0 ;
// Forecast burndown from resolution_dates
const { rows : forecastItems } = await pool . query (
` SELECT resolution_date FROM compliance_items WHERE status = 'active' AND COALESCE(team, 'Unknown') = $ 1 AND resolution_date IS NOT NULL ` ,
[ team ]
) ;
const forecast _burndown = computeForecastBurndown ( forecastItems ) ;
const blockers = teamNonCompliant - forecastItems . length ;
2026-05-11 15:48:10 -06:00
verticalBreakdown . push ( {
vertical : team ,
2026-05-13 07:57:41 -06:00
compliance _pct : compliance _pct _team ,
2026-05-11 15:48:10 -06:00
team : team ,
2026-05-13 07:57:41 -06:00
non _compliant : teamNonCompliant ,
actual _burndown : { } ,
2026-05-11 15:48:10 -06:00
forecast _burndown ,
2026-05-13 07:57:41 -06:00
blockers : blockers > 0 ? blockers : 0 ,
2026-05-11 15:48:10 -06:00
risk _acceptances : 0 ,
notes : '' ,
} ) ;
}
2026-05-13 07:57:41 -06:00
// Merge vertical metadata (notes, risk_acceptances, compliance_date)
try {
const { rows : metaRows } = await pool . query ( ` SELECT team, notes, risk_acceptances, compliance_date FROM vcl_vertical_metadata ` ) ;
const metaMap = { } ;
metaRows . forEach ( r => { metaMap [ r . team ] = r ; } ) ;
for ( const hh of heavy _hitters ) {
const meta = metaMap [ hh . vertical ] || metaMap [ hh . team ] ;
if ( meta ) {
hh . notes = meta . notes || '' ;
hh . compliance _date = meta . compliance _date || hh . compliance _date ;
}
}
for ( const vb of verticalBreakdown ) {
const meta = metaMap [ vb . vertical ] || metaMap [ vb . team ] ;
if ( meta ) {
vb . notes = meta . notes || '' ;
vb . risk _acceptances = meta . risk _acceptances || 0 ;
vb . compliance _date = meta . compliance _date || null ;
}
}
} catch ( metaErr ) {
// Non-critical — continue without metadata
console . error ( '[Compliance] VCL metadata merge error:' , metaErr . message ) ;
}
2026-05-11 15:48:10 -06:00
res . json ( { stats , donut , heavy _hitters , vertical _breakdown : verticalBreakdown } ) ;
} catch ( err ) {
console . error ( '[Compliance] GET /vcl/stats error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Database error' } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* GET / vcl / trend
* Returns monthly compliance trend data with actual percentages and linear regression forecast .
* Forecast is computed when 3 + months of historical data exist , projecting 3 months forward .
*
* @ response 200 { months : Array < { month , compliant _count , compliance _pct , forecast _pct , target _pct } > }
* @ response 500 { error } — database error
* /
2026-05-11 15:48:10 -06:00
router . get ( '/vcl/trend' , async ( req , res ) => {
try {
const { rows : snapshots } = await pool . query (
` SELECT snapshot_month, SUM(compliant)::int AS compliant_count,
CASE WHEN SUM ( total _devices ) > 0
THEN ROUND ( ( SUM ( compliant ) : : numeric / SUM ( total _devices ) : : numeric ) * 100 , 1 )
ELSE 0 END AS compliance _pct
FROM compliance _snapshots
GROUP BY snapshot _month
ORDER BY snapshot _month ASC `
) ;
// Build months array with actuals
const months = snapshots . map ( s => ( {
month : s . snapshot _month ,
compliant _count : s . compliant _count ,
compliance _pct : parseFloat ( s . compliance _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 ;
// Use last data points for regression
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 ) ) ;
// Compute the future month string
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 ,
} ) ;
}
// Also add forecast_pct to the last actual month as the starting point
if ( months . length > 0 && n > 0 ) {
months [ n - 1 ] . forecast _pct = months [ n - 1 ] . compliance _pct ;
}
}
res . json ( { months } ) ;
} catch ( err ) {
console . error ( '[Compliance] GET /vcl/trend error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Database error' } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* POST / vcl / bulk - preview
* Accepts parsed bulk upload rows , matches hostnames against active devices , validates fields ,
* and returns a diff preview showing matched / unmatched / changed / invalid row counts .
*
* @ body { rows : Array < { hostname , resolution _date ? , remediation _plan ? , notes ? } > , headers ? : string [ ] }
* @ response 200 { matched , unmatched , changes , invalid , details : Array < { hostname , status , fields ? } > , unmatched _rows : string [ ] , invalid _rows : Array < { hostname , errors } > }
* @ response 400 { error } — missing rows , exceeds 2000 rows , no Hostname column , or no updatable fields
* @ response 500 { error } — processing failure
* /
2026-05-11 15:48:10 -06:00
router . post ( '/vcl/bulk-preview' , requireGroup ( 'Admin' , 'Standard_User' ) , async ( req , res ) => {
const { rows , headers } = req . body ;
// Validate: require rows array
if ( ! rows || ! Array . isArray ( rows ) ) {
return res . status ( 400 ) . json ( { error : 'rows array is required' } ) ;
}
// Enforce 2000 row limit
if ( rows . length === 0 ) {
return res . status ( 400 ) . json ( { error : 'File contains no data rows' } ) ;
}
if ( rows . length > 2000 ) {
return res . status ( 400 ) . json ( { error : 'File exceeds maximum of 2000 rows' } ) ;
}
// Map column headers if provided
let columnMapping = { } ;
if ( headers && Array . isArray ( headers ) ) {
columnMapping = mapColumnHeaders ( headers ) ;
}
// Require hostname field
const hasHostname = rows . every ( r => r . hostname != null && r . hostname !== '' ) ;
if ( ! hasHostname ) {
return res . status ( 400 ) . json ( { error : 'File must contain a Hostname column' } ) ;
}
// Check for updatable fields (resolution_date, remediation_plan, or notes)
const sampleRow = rows [ 0 ] || { } ;
const updatableFields = [ 'resolution_date' , 'remediation_plan' , 'notes' ] ;
const hasUpdatableFields = updatableFields . some ( f => f in sampleRow ) ;
if ( ! hasUpdatableFields && headers ) {
// Check via column mapping
const mappedFields = Object . keys ( columnMapping ) . filter ( k => k !== 'hostname' ) ;
if ( mappedFields . length === 0 ) {
return res . status ( 400 ) . json ( { error : 'No updatable fields found (need Resolution Date, Remediation Plan, or Notes)' } ) ;
}
} else if ( ! hasUpdatableFields && ! headers ) {
return res . status ( 400 ) . json ( { error : 'No updatable fields found (need Resolution Date, Remediation Plan, or Notes)' } ) ;
}
try {
// Get existing hostnames from DB
const { rows : existingRows } = await pool . query (
` SELECT DISTINCT hostname FROM compliance_items WHERE status = 'active' `
) ;
const existingHostnames = new Set ( existingRows . map ( r => r . hostname ) ) ;
// Match by hostname
const { matched , unmatched } = matchByHostname ( rows , existingHostnames ) ;
// Validate fields on matched rows
const validRows = [ ] ;
const invalidRows = [ ] ;
for ( const row of matched ) {
const errors = [ ] ;
if ( row . resolution _date !== undefined && row . resolution _date !== null && row . resolution _date !== '' ) {
if ( ! isValidDateString ( row . resolution _date ) ) {
errors . push ( 'resolution_date: invalid date format' ) ;
}
}
if ( row . remediation _plan !== undefined && row . remediation _plan !== null ) {
const planCheck = validateRemediationPlan ( row . remediation _plan ) ;
if ( ! planCheck . valid ) {
errors . push ( 'remediation_plan: ' + planCheck . error ) ;
}
}
if ( errors . length > 0 ) {
invalidRows . push ( { hostname : row . hostname , errors } ) ;
} else {
validRows . push ( row ) ;
}
}
// Get current data for diff computation
const { rows : currentRows } = await pool . query (
` SELECT DISTINCT ON (hostname) hostname, resolution_date, remediation_plan
FROM compliance _items WHERE status = 'active' AND hostname = ANY ( $1 )
ORDER BY hostname , id DESC ` ,
[ validRows . map ( r => r . hostname ) ]
) ;
const currentData = new Map ( ) ;
for ( const row of currentRows ) {
currentData . set ( row . hostname , {
resolution _date : row . resolution _date ? row . resolution _date . toISOString ? . ( ) . slice ( 0 , 10 ) || String ( row . resolution _date ) . slice ( 0 , 10 ) : null ,
remediation _plan : row . remediation _plan || null ,
notes : null ,
} ) ;
}
// Compute diff
const diffResults = computeBulkDiff ( validRows , currentData ) ;
const changedRows = diffResults . filter ( r => r . status === 'changed' ) ;
res . json ( {
matched : matched . length ,
unmatched : unmatched . length ,
changes : changedRows . length ,
invalid : invalidRows . length ,
details : diffResults ,
unmatched _rows : unmatched . map ( r => r . hostname ) ,
invalid _rows : invalidRows ,
} ) ;
} catch ( err ) {
console . error ( '[Compliance] POST /vcl/bulk-preview error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to process bulk preview' } ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
/ * *
* POST / vcl / bulk - commit
* Commits validated bulk changes to compliance items in a single transaction .
* Updates resolution _date and / or remediation _plan for each hostname provided .
*
* @ body { changes : Array < { hostname , resolution _date ? , remediation _plan ? , notes ? } > }
* @ response 200 { committed : number }
* @ response 400 { error } — missing or empty changes array
* @ response 500 { error } — transaction failure ( full rollback )
* /
2026-05-11 15:48:10 -06:00
router . post ( '/vcl/bulk-commit' , requireGroup ( 'Admin' , 'Standard_User' ) , async ( req , res ) => {
const { changes } = req . body ;
if ( ! changes || ! Array . isArray ( changes ) || changes . length === 0 ) {
return res . status ( 400 ) . json ( { error : 'changes array is required' } ) ;
}
const client = await pool . connect ( ) ;
try {
await client . query ( 'BEGIN' ) ;
2026-05-15 10:53:14 -06:00
// Pre-fetch current values for all hostnames in the batch
const hostnames = changes . map ( c => c . hostname ) ;
const { rows : currentRows } = await client . query (
` SELECT DISTINCT ON (hostname) hostname, resolution_date, remediation_plan
FROM compliance _items WHERE status = 'active' AND hostname = ANY ( $1 )
ORDER BY hostname , id DESC ` ,
[ hostnames ]
) ;
const currentData = new Map ( ) ;
for ( const row of currentRows ) {
currentData . set ( row . hostname , {
resolution _date : row . resolution _date ? ( typeof row . resolution _date === 'string' ? row . resolution _date : row . resolution _date . toISOString ( ) . slice ( 0 , 10 ) ) : null ,
remediation _plan : row . remediation _plan || null ,
} ) ;
}
2026-05-11 15:48:10 -06:00
let committedCount = 0 ;
for ( const change of changes ) {
const setClauses = [ ] ;
const values = [ ] ;
let paramIdx = 1 ;
if ( change . resolution _date !== undefined ) {
setClauses . push ( ` resolution_date = $ ${ paramIdx ++ } ` ) ;
values . push ( change . resolution _date ) ;
}
if ( change . remediation _plan !== undefined ) {
setClauses . push ( ` remediation_plan = $ ${ paramIdx ++ } ` ) ;
values . push ( change . remediation _plan ) ;
}
if ( change . notes !== undefined ) {
// Notes are stored separately in compliance_notes, but we can update a field if it exists
// For now, skip notes in the direct update
}
if ( setClauses . length === 0 ) continue ;
2026-05-15 10:53:14 -06:00
// Record history for changed fields
const current = currentData . get ( change . hostname ) || { resolution _date : null , remediation _plan : null } ;
if ( change . resolution _date !== undefined ) {
const newVal = change . resolution _date || null ;
if ( current . resolution _date !== newVal ) {
await client . query (
` INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
VALUES ( $1 , 'resolution_date' , $2 , $3 , NULL , $4 ) ` ,
[ change . hostname , current . resolution _date , newVal , req . user . username ]
) ;
}
}
if ( change . remediation _plan !== undefined ) {
const newVal = change . remediation _plan || null ;
if ( current . remediation _plan !== newVal ) {
await client . query (
` INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
VALUES ( $1 , 'remediation_plan' , $2 , $3 , NULL , $4 ) ` ,
[ change . hostname , current . remediation _plan , newVal , req . user . username ]
) ;
}
}
2026-05-11 15:48:10 -06:00
values . push ( change . hostname ) ;
const result = await client . query (
` UPDATE compliance_items SET ${ setClauses . join ( ', ' ) } WHERE hostname = $ ${ paramIdx } AND status = 'active' ` ,
values
) ;
if ( result . rowCount > 0 ) committedCount ++ ;
}
await client . query ( 'COMMIT' ) ;
logAudit ( {
userId : req . user . id ,
username : req . user . username ,
action : 'compliance_bulk_update' ,
entityType : 'compliance_items' ,
entityId : null ,
details : { rows _updated : committedCount , total _changes : changes . length } ,
ipAddress : req . ip ,
} ) ;
res . json ( { committed : committedCount } ) ;
} catch ( err ) {
await client . query ( 'ROLLBACK' ) ;
console . error ( '[Compliance] POST /vcl/bulk-commit error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to commit changes' } ) ;
} finally {
client . release ( ) ;
}
} ) ;
2026-05-13 07:57:41 -06:00
// -----------------------------------------------------------------------
// VCL Vertical Metadata endpoints
// -----------------------------------------------------------------------
/ * *
* GET / vcl / vertical - metadata
* Returns all rows from vcl _vertical _metadata .
*
* @ response 200 { metadata : Array < { id , team , notes , risk _acceptances , compliance _date , updated _at } > }
* @ response 500 { error } — database error
* /
router . get ( '/vcl/vertical-metadata' , async ( req , res ) => {
try {
const { rows } = await pool . query (
` SELECT id, team, notes, risk_acceptances, compliance_date, updated_at FROM vcl_vertical_metadata ORDER BY team `
) ;
res . json ( { metadata : rows } ) ;
} catch ( err ) {
console . error ( '[Compliance] GET /vcl/vertical-metadata error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Database error' } ) ;
}
} ) ;
/ * *
* PATCH / vcl / vertical - metadata / : team
* Upserts notes , risk _acceptances , and / or compliance _date for a team .
*
* @ param team — the team / vertical name
* @ body { notes ? : string , risk _acceptances ? : number , compliance _date ? : string | null }
* @ response 200 { success : true }
* @ response 400 { error } — no fields provided or invalid values
* @ response 500 { error } — database error
* /
router . patch ( '/vcl/vertical-metadata/:team' , requireGroup ( 'Admin' , 'Standard_User' ) , async ( req , res ) => {
const team = req . params . team ;
if ( ! team || team . length > 100 ) return res . status ( 400 ) . json ( { error : 'Invalid team' } ) ;
const { notes , risk _acceptances , compliance _date } = req . body ;
if ( notes === undefined && risk _acceptances === undefined && compliance _date === undefined ) {
return res . status ( 400 ) . json ( { error : 'No fields to update' } ) ;
}
if ( risk _acceptances !== undefined && risk _acceptances !== null ) {
if ( typeof risk _acceptances !== 'number' || risk _acceptances < 0 || ! Number . isInteger ( risk _acceptances ) ) {
return res . status ( 400 ) . json ( { error : 'risk_acceptances must be a non-negative integer' } ) ;
}
}
if ( compliance _date !== undefined && compliance _date !== null && compliance _date !== '' ) {
if ( typeof compliance _date !== 'string' || compliance _date . length > 50 ) {
return res . status ( 400 ) . json ( { error : 'compliance_date must be a string (max 50 chars)' } ) ;
}
}
try {
// Build the upsert dynamically
const upsertNotes = notes !== undefined ? notes : '' ;
const upsertRAs = risk _acceptances !== undefined ? risk _acceptances : 0 ;
const upsertDate = compliance _date !== undefined ? ( compliance _date || null ) : null ;
// Use ON CONFLICT to insert or update only the provided fields
const updateParts = [ ] ;
if ( notes !== undefined ) updateParts . push ( 'notes = EXCLUDED.notes' ) ;
if ( risk _acceptances !== undefined ) updateParts . push ( 'risk_acceptances = EXCLUDED.risk_acceptances' ) ;
if ( compliance _date !== undefined ) updateParts . push ( 'compliance_date = EXCLUDED.compliance_date' ) ;
updateParts . push ( 'updated_at = NOW()' ) ;
await pool . query (
` INSERT INTO vcl_vertical_metadata (team, notes, risk_acceptances, compliance_date, updated_at)
VALUES ( $1 , $2 , $3 , $4 , NOW ( ) )
ON CONFLICT ( team ) DO UPDATE SET $ { updateParts . join ( ', ' ) } ` ,
[ team , upsertNotes , upsertRAs , upsertDate ]
) ;
res . json ( { success : true } ) ;
} catch ( err ) {
console . error ( '[Compliance] PATCH /vcl/vertical-metadata error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to update vertical metadata' } ) ;
}
} ) ;
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
return router ;
}
2026-05-01 17:15:41 +00:00
module . exports = { createComplianceRouter , bucketAgingItems , computeWaterfall } ;