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
// POST /preview
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-04-20 20:12:12 +00:00
// POST /reconcile-config
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 } ) ;
}
} ) ;
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
// POST /commit
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 } ) ;
}
} ) ;
// GET /uploads
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-04-20 20:12:12 +00:00
// POST /rollback/:uploadId
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-06 11:44:17 -06:00
// GET /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
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-06 11:44:17 -06:00
const { rows : latestRows } = await pool . query (
` SELECT id, summary_json, report_date, uploaded_at FROM compliance_uploads 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-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-06 11:44:17 -06:00
// GET /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
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-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
WHERE ci . team = $1 AND ci . status = $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
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' } ) ;
}
} ) ;
// GET /items/:hostname
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,
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
) ;
const identity = metricRows . find ( r => r . status === 'active' ) || metricRows [ 0 ] ;
2026-05-06 11:44:17 -06:00
res . json ( { hostname , ip _address : identity . ip _address || '' , device _type : identity . device _type || '' , team : identity . team || '' , metrics , 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 ) {
console . error ( '[Compliance] GET /items/:hostname error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Database error' } ) ;
}
} ) ;
// POST /notes
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
}
} ) ;
// GET /notes/:hostname/:metricId
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-04-20 21:39:43 +00:00
// DELETE /notes/:id
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-04-02 09:49:32 -06:00
// GET /trends
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' } ) ;
}
} ) ;
// GET /mttr
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' } ) ;
}
} ) ;
// GET /top-recurring
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' } ) ;
}
} ) ;
// GET /category-trend
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-11 15:48:10 -06:00
// -----------------------------------------------------------------------
// PATCH /items/:hostname/metadata — Update resolution_date / remediation_plan
// -----------------------------------------------------------------------
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' } ) ;
const { resolution _date , remediation _plan } = req . body ;
// 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 } ) ;
}
}
try {
// Build dynamic SET clause for provided fields only
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' } ) ;
}
values . push ( hostname ) ;
const result = await pool . query (
` UPDATE compliance_items SET ${ setClauses . join ( ', ' ) } WHERE hostname = $ ${ paramIdx } AND status = 'active' ` ,
values
) ;
if ( result . rowCount === 0 ) {
return res . status ( 404 ) . json ( { error : 'Device not found' } ) ;
}
logAudit ( {
userId : req . user . id ,
username : req . user . username ,
action : 'compliance_metadata_update' ,
entityType : 'compliance_item' ,
entityId : hostname ,
details : { resolution _date , remediation _plan } ,
ipAddress : req . ip ,
} ) ;
res . json ( { updated : result . rowCount } ) ;
} catch ( err ) {
console . error ( '[Compliance] PATCH /items/:hostname/metadata error:' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to update device metadata' } ) ;
}
} ) ;
// -----------------------------------------------------------------------
// GET /vcl/stats — VCL executive summary statistics
// -----------------------------------------------------------------------
const VCL _TARGET _PCT = parseInt ( process . env . VCL _TARGET _PCT , 10 ) || 95 ;
router . get ( '/vcl/stats' , async ( req , res ) => {
try {
// Fetch all active compliance items
const { rows : items } = await pool . query (
` SELECT hostname, team, status, resolution_date, remediation_plan,
CASE WHEN status = 'resolved' THEN true ELSE false END AS is _compliant ,
true AS in _scope
FROM compliance _items WHERE status = 'active' `
) ;
// For stats computation, all active items are non-compliant (they are findings)
// We need total in-scope devices (active + resolved from latest upload)
const { rows : latestUploadRows } = await pool . query (
` SELECT id FROM compliance_uploads ORDER BY id DESC LIMIT 1 `
) ;
let allDeviceItems = [ ] ;
if ( latestUploadRows . length > 0 ) {
const { rows : allItems } = await pool . query (
` SELECT hostname, team, status, resolution_date, remediation_plan,
CASE WHEN status = 'resolved' THEN true ELSE false END AS is _compliant ,
true AS in _scope
FROM compliance _items `
) ;
// Deduplicate by hostname — a device is compliant if it has no active findings
const deviceMap = new Map ( ) ;
for ( const item of allItems ) {
const existing = deviceMap . get ( item . hostname ) ;
if ( ! existing ) {
deviceMap . set ( item . hostname , { ... item , is _compliant : item . status !== 'active' , in _scope : true } ) ;
} else if ( item . status === 'active' ) {
existing . is _compliant = false ;
}
}
allDeviceItems = Array . from ( deviceMap . values ( ) ) ;
}
const stats = computeVCLStats ( allDeviceItems , VCL _TARGET _PCT ) ;
// Donut: categorize non-compliant items by resolution_date presence
const nonCompliantItems = items . filter ( i => i . status === 'active' ) ;
const donut = categorizeNonCompliant ( nonCompliantItems ) ;
// Heavy hitters: group by team, count non-compliant per team
const teamCounts = { } ;
for ( const item of nonCompliantItems ) {
const team = item . team || 'Unknown' ;
if ( ! teamCounts [ team ] ) {
teamCounts [ team ] = { vertical : team , team : team , non _compliant : 0 , compliance _date : null , notes : '' } ;
}
teamCounts [ team ] . non _compliant ++ ;
// Use the latest resolution_date as the team's compliance_date
if ( item . resolution _date && ( ! teamCounts [ team ] . compliance _date || item . resolution _date > teamCounts [ team ] . compliance _date ) ) {
teamCounts [ team ] . compliance _date = item . resolution _date ;
}
}
const heavy _hitters = rankHeavyHitters ( Object . values ( teamCounts ) ) ;
// Vertical breakdown with burndown
const verticalBreakdown = [ ] ;
for ( const team of Object . keys ( teamCounts ) ) {
const teamItems = nonCompliantItems . filter ( i => ( i . team || 'Unknown' ) === team ) ;
const teamAllDevices = allDeviceItems . filter ( i => ( i . team || 'Unknown' ) === team ) ;
const teamTotal = teamAllDevices . length ;
const teamCompliant = teamAllDevices . filter ( i => i . is _compliant ) . length ;
const compliance _pct = teamTotal > 0 ? Math . round ( ( teamCompliant / teamTotal ) * 100 ) : 0 ;
const actual _burndown = computeForecastBurndown ( teamItems . filter ( i => i . resolution _date ) ) ;
const forecast _burndown = computeForecastBurndown ( teamItems . filter ( i => i . resolution _date ) ) ;
const blockers = teamItems . filter ( i => ! i . resolution _date ) . length ;
verticalBreakdown . push ( {
vertical : team ,
compliance _pct ,
team : team ,
non _compliant : teamItems . length ,
actual _burndown ,
forecast _burndown ,
blockers ,
risk _acceptances : 0 ,
notes : '' ,
} ) ;
}
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' } ) ;
}
} ) ;
// -----------------------------------------------------------------------
// GET /vcl/trend — Monthly compliance trend with forecast
// -----------------------------------------------------------------------
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' } ) ;
}
} ) ;
// -----------------------------------------------------------------------
// POST /vcl/bulk-preview — Bulk upload diff preview
// -----------------------------------------------------------------------
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' } ) ;
}
} ) ;
// -----------------------------------------------------------------------
// POST /vcl/bulk-commit — Commit validated bulk changes
// -----------------------------------------------------------------------
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' ) ;
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 ;
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 ( ) ;
}
} ) ;
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 } ;