diff --git a/backend/migrations/add_vcl_vertical_metadata.js b/backend/migrations/add_vcl_vertical_metadata.js new file mode 100644 index 0000000..d41f3e1 --- /dev/null +++ b/backend/migrations/add_vcl_vertical_metadata.js @@ -0,0 +1,26 @@ +// Migration: Create vcl_vertical_metadata table for editable team-level notes, RAs, and compliance dates +const pool = require('../db'); + +async function run() { + console.log('Starting vcl_vertical_metadata migration...'); + try { + await pool.query(` + CREATE TABLE IF NOT EXISTS vcl_vertical_metadata ( + id SERIAL PRIMARY KEY, + team TEXT NOT NULL UNIQUE, + notes TEXT DEFAULT '', + risk_acceptances INTEGER DEFAULT 0, + compliance_date TEXT DEFAULT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + console.log('✓ vcl_vertical_metadata table created (or already exists)'); + } catch (err) { + console.error('Migration error:', err.message); + process.exit(1); + } + console.log('Migration complete.'); + process.exit(0); +} + +run(); diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js index 738fa21..4828ad0 100644 --- a/backend/migrations/run-all.js +++ b/backend/migrations/run-all.js @@ -17,6 +17,7 @@ const POSTGRES_MIGRATIONS = [ 'add_decom_workflow_type.js', 'add_fp_submissions_dismissed.js', 'add_vcl_reporting_columns.js', + 'add_vcl_vertical_metadata.js', ]; async function runAll() { diff --git a/backend/routes/compliance.js b/backend/routes/compliance.js index 4e4f0dd..8b7f3ed 100644 --- a/backend/routes/compliance.js +++ b/backend/routes/compliance.js @@ -258,7 +258,17 @@ function createComplianceRouter(upload) { // All compliance routes require authentication router.use(requireAuth()); - // POST /preview + /** + * POST /preview + * Uploads an xlsx file, parses it, computes a diff against current active items, + * performs schema drift detection, and stores parsed data in a temp file for later commit. + * + * @body multipart/form-data — field "file" (xlsx spreadsheet, max 10MB) + * @response 200 { drift, drift_error, schema, diff: { new_count, recurring_count, resolved_count }, tempFile, filename, report_date, total_items } + * @response 400 { error } — no file, wrong extension, or upload error + * @response 422 { error } — parser returned an error + * @response 500 { error } — config load failure or parse failure + */ router.post('/preview', requireGroup('Admin', 'Standard_User'), (req, res) => { upload.single('file')(req, res, async (uploadErr) => { if (uploadErr) return res.status(400).json({ error: uploadErr.message }); @@ -320,7 +330,15 @@ function createComplianceRouter(upload) { }); }); - // POST /reconcile-config + /** + * POST /reconcile-config + * Applies schema drift reconciliation to the compliance config file based on detected drift findings. + * + * @body { drift: object, schema?: object } + * @response 200 { changes: Array<{ action, key, value, detail }>, message } + * @response 400 { error } — missing drift or no findings to reconcile + * @response 500 { error } — reconciliation failure + */ router.post('/reconcile-config', requireGroup('Admin'), async (req, res) => { const { drift, schema } = req.body; if (!drift || typeof drift !== 'object') return res.status(400).json({ error: 'drift report is required in request body' }); @@ -341,7 +359,16 @@ function createComplianceRouter(upload) { } }); - // POST /commit + /** + * POST /commit + * Commits a previously previewed compliance upload to the database. Resolves items no longer + * present, upserts recurring/new items, and creates a compliance snapshot for the current month. + * + * @body { tempFile: string, filename?: string, report_date?: string } + * @response 200 { upload: { id, filename, report_date, uploaded_at, new_count, resolved_count, recurring_count } } + * @response 400 { error } — missing/invalid tempFile or expired preview session + * @response 500 { error } — commit failure + */ router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => { const { tempFile, filename, report_date } = req.body; if (!tempFile || typeof tempFile !== 'string') return res.status(400).json({ error: 'tempFile is required' }); @@ -372,7 +399,13 @@ function createComplianceRouter(upload) { } }); - // GET /uploads + /** + * GET /uploads + * Returns all compliance upload records ordered by most recent first. + * + * @response 200 { uploads: Array<{ id, filename, report_date, uploaded_at, new_count, resolved_count, recurring_count }> } + * @response 500 { error } — database error + */ router.get('/uploads', async (req, res) => { try { const { rows } = await pool.query( @@ -386,7 +419,17 @@ function createComplianceRouter(upload) { } }); - // POST /rollback/:uploadId + /** + * POST /rollback/:uploadId + * Rolls back the most recent compliance upload — deletes new items introduced by that upload, + * reactivates items it resolved, and removes the upload record. + * + * @param uploadId — numeric ID of the upload to roll back (must be the latest) + * @response 200 { message, rolled_back: { upload_id, filename, report_date, items_deleted, items_reactivated } } + * @response 400 { error } — invalid ID or not the latest upload + * @response 404 { error } — upload not found + * @response 500 { error } — rollback failure + */ router.post('/rollback/:uploadId', requireGroup('Admin'), async (req, res) => { const uploadId = parseInt(req.params.uploadId, 10); if (isNaN(uploadId)) return res.status(400).json({ error: 'Invalid upload ID' }); @@ -440,7 +483,15 @@ function createComplianceRouter(upload) { } }); - // GET /summary + /** + * GET /summary + * Returns the summary data from the most recent compliance upload, optionally filtered by team. + * + * @query team — optional, one of STEAM | ACCESS-ENG | ACCESS-OPS | INTELDEV + * @response 200 { entries: Array, overall_scores: object, upload: { id, report_date, uploaded_at } | null } + * @response 400 { error } — invalid team + * @response 500 { error } — database error + */ router.get('/summary', async (req, res) => { const team = req.query.team; if (team && !ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' }); @@ -465,7 +516,16 @@ function createComplianceRouter(upload) { } }); - // GET /items + /** + * GET /items + * Returns compliance items grouped by hostname for a given team and status. + * + * @query team — required, one of STEAM | ACCESS-ENG | ACCESS-OPS | INTELDEV + * @query status — optional, "active" (default) or "resolved" + * @response 200 { devices: Array<{ hostname, ip_address, device_type, team, status, failing_metrics, seen_count, first_seen, last_seen, resolved_on, has_notes }>, team, status } + * @response 400 { error } — missing/invalid team or invalid status + * @response 500 { error } — database error + */ router.get('/items', async (req, res) => { const { team, status = 'active' } = req.query; if (!team) return res.status(400).json({ error: 'team is required' }); @@ -495,7 +555,16 @@ function createComplianceRouter(upload) { } }); - // GET /items/:hostname + /** + * GET /items/:hostname + * Returns detailed information for a single device including all metrics and notes. + * + * @param hostname — the device hostname + * @response 200 { hostname, ip_address, device_type, team, metrics: Array<{ metric_id, metric_desc, category, status, ... }>, notes: Array<{ id, metric_id, note, group_id, created_at, created_by }> } + * @response 400 { error } — invalid hostname + * @response 404 { error } — device not found + * @response 500 { error } — database error + */ router.get('/items/:hostname', async (req, res) => { const hostname = req.params.hostname; if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' }); @@ -503,6 +572,7 @@ function createComplianceRouter(upload) { try { 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, + ci.resolution_date, ci.remediation_plan, 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 FROM compliance_items ci LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id @@ -522,14 +592,25 @@ function createComplianceRouter(upload) { ); const identity = metricRows.find(r => r.status === 'active') || metricRows[0]; - res.json({ hostname, ip_address: identity.ip_address || '', device_type: identity.device_type || '', team: identity.team || '', metrics, notes }); + // Return resolution_date and remediation_plan from the first active item (or any item) + const resDate = identity.resolution_date ? (typeof identity.resolution_date === 'string' ? identity.resolution_date : identity.resolution_date.toISOString().slice(0, 10)) : null; + res.json({ hostname, ip_address: identity.ip_address || '', device_type: identity.device_type || '', team: identity.team || '', resolution_date: resDate, remediation_plan: identity.remediation_plan || '', metrics, notes }); } catch (err) { console.error('[Compliance] GET /items/:hostname error:', err.message); res.status(500).json({ error: 'Database error' }); } }); - // POST /notes + /** + * POST /notes + * Creates one or more compliance notes for a device, linked to specific metric IDs. + * All notes in a single call share a group_id for batch operations. + * + * @body { hostname: string, metric_id?: string, metric_ids?: string[], note: string } + * @response 201 { notes: Array<{ id, hostname, metric_id, note, group_id, created_at, created_by }> } + * @response 400 { error } — invalid hostname, missing/invalid metric_id(s), or empty note + * @response 500 { error } — save failure + */ router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => { const { hostname, metric_id, metric_ids, note } = req.body; if (!hostname || typeof hostname !== 'string' || hostname.length > 300 || !/^[a-zA-Z0-9._-]+$/.test(hostname)) return res.status(400).json({ error: 'Invalid hostname format' }); @@ -585,7 +666,16 @@ function createComplianceRouter(upload) { } }); - // GET /notes/:hostname/:metricId + /** + * GET /notes/:hostname/:metricId + * Returns all notes for a specific device and metric combination. + * + * @param hostname — the device hostname + * @param metricId — the metric identifier + * @response 200 { notes: Array<{ id, note, created_at, created_by }> } + * @response 400 { error } — invalid hostname or metricId + * @response 500 { error } — database error + */ 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' }); @@ -604,7 +694,19 @@ function createComplianceRouter(upload) { } }); - // DELETE /notes/:id + /** + * DELETE /notes/:id + * Deletes a compliance note by ID. Only the note author or an Admin can delete. + * Optionally deletes all notes in the same group when ?group=true. + * + * @param id — numeric note ID + * @query group — optional, "true" to delete all notes sharing the same group_id + * @response 200 { deleted: number } + * @response 400 { error } — invalid note ID + * @response 403 { error } — not the author and not Admin + * @response 404 { error } — note not found + * @response 500 { error } — delete failure + */ 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' }); @@ -636,7 +738,13 @@ function createComplianceRouter(upload) { } }); - // GET /trends + /** + * GET /trends + * Returns historical compliance upload trends with per-team breakdowns for charting. + * + * @response 200 { trends: Array<{ report_date, new_count, recurring_count, resolved_count, total_active, STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV }> } + * @response 500 { error } — database error + */ router.get('/trends', async (req, res) => { try { const { rows: uploads } = await pool.query( @@ -661,7 +769,13 @@ function createComplianceRouter(upload) { } }); - // GET /mttr + /** + * GET /mttr + * Returns aging bucket distribution of active compliance items (1 cycle, 2–3, 4–6, 7+) with per-team counts. + * + * @response 200 { aging: Array<{ bucket, total, STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV }> } + * @response 500 { error } — database error + */ router.get('/mttr', async (req, res) => { try { const { rows } = await pool.query(`SELECT COALESCE(seen_count, 1) AS seen_count, team FROM compliance_items WHERE status = 'active'`); @@ -674,7 +788,13 @@ function createComplianceRouter(upload) { } }); - // GET /top-recurring + /** + * GET /top-recurring + * Returns waterfall chart data computed from upload history (start, new, recurring, resolved, end per upload). + * + * @response 200 { waterfall: Array<{ date, start, new_count, recurring_count, resolved_count, end }> } + * @response 500 { error } — database error + */ router.get('/top-recurring', async (req, res) => { try { const { rows } = await pool.query( @@ -688,7 +808,13 @@ function createComplianceRouter(upload) { } }); - // GET /category-trend + /** + * GET /category-trend + * Returns per-upload category breakdown counts for trend charting. + * + * @response 200 { categoryTrend: Array<{ report_date, category, count }> } + * @response 500 { error } — database error + */ router.get('/category-trend', async (req, res) => { try { const { rows } = await pool.query( @@ -703,9 +829,17 @@ function createComplianceRouter(upload) { } }); - // ----------------------------------------------------------------------- - // PATCH /items/:hostname/metadata — Update resolution_date / remediation_plan - // ----------------------------------------------------------------------- + /** + * PATCH /items/:hostname/metadata + * Updates resolution_date and/or remediation_plan for all active compliance items matching a hostname. + * + * @param hostname — the device hostname + * @body { resolution_date?: string|null, remediation_plan?: string|null } + * @response 200 { updated: number } + * @response 400 { error } — invalid hostname, invalid date format, plan exceeds 2000 chars, or no fields provided + * @response 404 { error } — device not found + * @response 500 { error } — update failure + */ 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' }); @@ -748,7 +882,7 @@ function createComplianceRouter(upload) { values.push(hostname); const result = await pool.query( - `UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`, + `UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx}`, values ); @@ -773,95 +907,144 @@ function createComplianceRouter(upload) { } }); - // ----------------------------------------------------------------------- - // GET /vcl/stats — VCL executive summary statistics - // ----------------------------------------------------------------------- + /** + * GET /vcl/stats + * Returns VCL executive summary statistics including device counts, compliance percentage, + * non-compliant asset categorization (donut), heavy hitters by team, and vertical breakdown with burndown. + * + * @response 200 { stats: { total_devices, in_scope, compliant, non_compliant, remediations_required, compliance_pct, target_pct }, donut: { blocked: { count, pct }, in_progress: { count, pct } }, heavy_hitters: Array<{ vertical, team, non_compliant, compliance_date, notes }>, vertical_breakdown: Array<{ vertical, compliance_pct, team, non_compliant, actual_burndown, forecast_burndown, blockers, risk_acceptances, notes }> } + * @response 500 { error } — database error + */ 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'` - ); + // Compute device-level stats using DISTINCT hostname + // A device is "compliant" if it has NO active findings + const { rows: statsRows } = await pool.query(` + SELECT + COUNT(DISTINCT hostname) AS total_devices, + COUNT(DISTINCT hostname) AS in_scope, + COUNT(DISTINCT CASE + WHEN hostname NOT IN (SELECT DISTINCT hostname FROM compliance_items WHERE status = 'active') + THEN hostname END) AS compliant, + COUNT(DISTINCT CASE + WHEN hostname IN (SELECT DISTINCT hostname FROM compliance_items WHERE status = 'active') + THEN hostname END) AS non_compliant + FROM compliance_items + `); - // 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` - ); + const raw = statsRows[0] || {}; + const total_devices = parseInt(raw.total_devices) || 0; + const in_scope = parseInt(raw.in_scope) || 0; + const compliant = parseInt(raw.compliant) || 0; + const non_compliant = parseInt(raw.non_compliant) || 0; + const compliance_pct = in_scope > 0 ? Math.round((compliant / in_scope) * 100) : 0; - 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 = { + total_devices, + in_scope, + compliant, + non_compliant, + remediations_required: non_compliant, + compliance_pct, + target_pct: VCL_TARGET_PCT, + }; - const stats = computeVCLStats(allDeviceItems, VCL_TARGET_PCT); + // Donut: categorize non-compliant DEVICES by resolution_date presence + // A device is "blocked" if it has no resolution_date on any of its active findings + // A device is "in_progress" if at least one active finding has a resolution_date + const { rows: donutRows } = await pool.query(` + SELECT + hostname, + MAX(resolution_date) AS resolution_date + FROM compliance_items + WHERE status = 'active' + GROUP BY hostname + `); + const donut = categorizeNonCompliant(donutRows); - // 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)); + // Heavy hitters: group by team, count non-compliant DEVICES per team + const { rows: teamRows } = await pool.query(` + SELECT + COALESCE(team, 'Unknown') AS team, + COUNT(DISTINCT hostname) AS non_compliant, + MAX(resolution_date) AS compliance_date + FROM compliance_items + WHERE status = 'active' + GROUP BY team + ORDER BY COUNT(DISTINCT hostname) DESC + `); + const heavy_hitters = teamRows.map(r => ({ + vertical: r.team, + team: r.team, + non_compliant: parseInt(r.non_compliant), + compliance_date: r.compliance_date ? r.compliance_date.toISOString().slice(0, 10) : null, + notes: '', + })); // 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; + for (const teamRow of teamRows) { + const team = teamRow.team; + const teamNonCompliant = parseInt(teamRow.non_compliant); - 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; + // Get total devices for this team (all statuses) + const { rows: teamTotalRows } = await pool.query( + `SELECT COUNT(DISTINCT hostname) AS total FROM compliance_items WHERE COALESCE(team, 'Unknown') = $1`, + [team] + ); + const teamTotal = parseInt(teamTotalRows[0]?.total) || 0; + const teamCompliant = teamTotal - teamNonCompliant; + const compliance_pct_team = teamTotal > 0 ? Math.round((teamCompliant / teamTotal) * 100) : 0; + + // Forecast burndown from resolution_dates + const { rows: forecastItems } = await pool.query( + `SELECT resolution_date FROM compliance_items WHERE status = 'active' AND COALESCE(team, 'Unknown') = $1 AND resolution_date IS NOT NULL`, + [team] + ); + const forecast_burndown = computeForecastBurndown(forecastItems); + const blockers = teamNonCompliant - forecastItems.length; verticalBreakdown.push({ vertical: team, - compliance_pct, + compliance_pct: compliance_pct_team, team: team, - non_compliant: teamItems.length, - actual_burndown, + non_compliant: teamNonCompliant, + actual_burndown: {}, forecast_burndown, - blockers, + blockers: blockers > 0 ? blockers : 0, risk_acceptances: 0, notes: '', }); } + // Merge vertical metadata (notes, risk_acceptances, compliance_date) + try { + const { rows: metaRows } = await pool.query(`SELECT team, notes, risk_acceptances, compliance_date FROM vcl_vertical_metadata`); + const metaMap = {}; + metaRows.forEach(r => { metaMap[r.team] = r; }); + + for (const hh of heavy_hitters) { + const meta = metaMap[hh.vertical] || metaMap[hh.team]; + if (meta) { + hh.notes = meta.notes || ''; + hh.compliance_date = meta.compliance_date || hh.compliance_date; + } + } + for (const vb of verticalBreakdown) { + const meta = metaMap[vb.vertical] || metaMap[vb.team]; + if (meta) { + vb.notes = meta.notes || ''; + vb.risk_acceptances = meta.risk_acceptances || 0; + vb.compliance_date = meta.compliance_date || null; + } + } + } catch (metaErr) { + // Non-critical — continue without metadata + console.error('[Compliance] VCL metadata merge error:', metaErr.message); + } + res.json({ stats, donut, heavy_hitters, vertical_breakdown: verticalBreakdown }); } catch (err) { console.error('[Compliance] GET /vcl/stats error:', err.message); @@ -869,9 +1052,14 @@ function createComplianceRouter(upload) { } }); - // ----------------------------------------------------------------------- - // GET /vcl/trend — Monthly compliance trend with forecast - // ----------------------------------------------------------------------- + /** + * GET /vcl/trend + * Returns monthly compliance trend data with actual percentages and linear regression forecast. + * Forecast is computed when 3+ months of historical data exist, projecting 3 months forward. + * + * @response 200 { months: Array<{ month, compliant_count, compliance_pct, forecast_pct, target_pct }> } + * @response 500 { error } — database error + */ router.get('/vcl/trend', async (req, res) => { try { const { rows: snapshots } = await pool.query( @@ -940,9 +1128,16 @@ function createComplianceRouter(upload) { } }); - // ----------------------------------------------------------------------- - // POST /vcl/bulk-preview — Bulk upload diff preview - // ----------------------------------------------------------------------- + /** + * POST /vcl/bulk-preview + * Accepts parsed bulk upload rows, matches hostnames against active devices, validates fields, + * and returns a diff preview showing matched/unmatched/changed/invalid row counts. + * + * @body { rows: Array<{ hostname, resolution_date?, remediation_plan?, notes? }>, headers?: string[] } + * @response 200 { matched, unmatched, changes, invalid, details: Array<{ hostname, status, fields? }>, unmatched_rows: string[], invalid_rows: Array<{ hostname, errors }> } + * @response 400 { error } — missing rows, exceeds 2000 rows, no Hostname column, or no updatable fields + * @response 500 { error } — processing failure + */ router.post('/vcl/bulk-preview', requireGroup('Admin', 'Standard_User'), async (req, res) => { const { rows, headers } = req.body; @@ -1057,9 +1252,16 @@ function createComplianceRouter(upload) { } }); - // ----------------------------------------------------------------------- - // POST /vcl/bulk-commit — Commit validated bulk changes - // ----------------------------------------------------------------------- + /** + * POST /vcl/bulk-commit + * Commits validated bulk changes to compliance items in a single transaction. + * Updates resolution_date and/or remediation_plan for each hostname provided. + * + * @body { changes: Array<{ hostname, resolution_date?, remediation_plan?, notes? }> } + * @response 200 { committed: number } + * @response 400 { error } — missing or empty changes array + * @response 500 { error } — transaction failure (full rollback) + */ router.post('/vcl/bulk-commit', requireGroup('Admin', 'Standard_User'), async (req, res) => { const { changes } = req.body; @@ -1122,6 +1324,88 @@ function createComplianceRouter(upload) { } }); + // ----------------------------------------------------------------------- + // VCL Vertical Metadata endpoints + // ----------------------------------------------------------------------- + + /** + * GET /vcl/vertical-metadata + * Returns all rows from vcl_vertical_metadata. + * + * @response 200 { metadata: Array<{ id, team, notes, risk_acceptances, compliance_date, updated_at }> } + * @response 500 { error } — database error + */ + router.get('/vcl/vertical-metadata', async (req, res) => { + try { + const { rows } = await pool.query( + `SELECT id, team, notes, risk_acceptances, compliance_date, updated_at FROM vcl_vertical_metadata ORDER BY team` + ); + res.json({ metadata: rows }); + } catch (err) { + console.error('[Compliance] GET /vcl/vertical-metadata error:', err.message); + res.status(500).json({ error: 'Database error' }); + } + }); + + /** + * PATCH /vcl/vertical-metadata/:team + * Upserts notes, risk_acceptances, and/or compliance_date for a team. + * + * @param team — the team/vertical name + * @body { notes?: string, risk_acceptances?: number, compliance_date?: string|null } + * @response 200 { success: true } + * @response 400 { error } — no fields provided or invalid values + * @response 500 { error } — database error + */ + router.patch('/vcl/vertical-metadata/:team', requireGroup('Admin', 'Standard_User'), async (req, res) => { + const team = req.params.team; + if (!team || team.length > 100) return res.status(400).json({ error: 'Invalid team' }); + + const { notes, risk_acceptances, compliance_date } = req.body; + + if (notes === undefined && risk_acceptances === undefined && compliance_date === undefined) { + return res.status(400).json({ error: 'No fields to update' }); + } + + if (risk_acceptances !== undefined && risk_acceptances !== null) { + if (typeof risk_acceptances !== 'number' || risk_acceptances < 0 || !Number.isInteger(risk_acceptances)) { + return res.status(400).json({ error: 'risk_acceptances must be a non-negative integer' }); + } + } + + if (compliance_date !== undefined && compliance_date !== null && compliance_date !== '') { + if (typeof compliance_date !== 'string' || compliance_date.length > 50) { + return res.status(400).json({ error: 'compliance_date must be a string (max 50 chars)' }); + } + } + + try { + // Build the upsert dynamically + const upsertNotes = notes !== undefined ? notes : ''; + const upsertRAs = risk_acceptances !== undefined ? risk_acceptances : 0; + const upsertDate = compliance_date !== undefined ? (compliance_date || null) : null; + + // Use ON CONFLICT to insert or update only the provided fields + const updateParts = []; + if (notes !== undefined) updateParts.push('notes = EXCLUDED.notes'); + if (risk_acceptances !== undefined) updateParts.push('risk_acceptances = EXCLUDED.risk_acceptances'); + if (compliance_date !== undefined) updateParts.push('compliance_date = EXCLUDED.compliance_date'); + updateParts.push('updated_at = NOW()'); + + await pool.query( + `INSERT INTO vcl_vertical_metadata (team, notes, risk_acceptances, compliance_date, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (team) DO UPDATE SET ${updateParts.join(', ')}`, + [team, upsertNotes, upsertRAs, upsertDate] + ); + + res.json({ success: true }); + } catch (err) { + console.error('[Compliance] PATCH /vcl/vertical-metadata error:', err.message); + res.status(500).json({ error: 'Failed to update vertical metadata' }); + } + }); + return router; } diff --git a/frontend/src/components/pages/VCLReportPage.js b/frontend/src/components/pages/VCLReportPage.js index 423d1ba..9329c6a 100644 --- a/frontend/src/components/pages/VCLReportPage.js +++ b/frontend/src/components/pages/VCLReportPage.js @@ -183,10 +183,89 @@ function NonCompliantDonutChart({ donut }) { ); } +// --------------------------------------------------------------------------- +// Inline Editable Cell Component +// --------------------------------------------------------------------------- +function EditableCell({ value, type, onSave, style, placeholder }) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value || ''); + + useEffect(() => { + setDraft(value || ''); + }, [value]); + + const handleSave = () => { + setEditing(false); + const newValue = type === 'number' ? (draft === '' ? 0 : parseInt(draft, 10)) : draft; + if (newValue !== value) { + onSave(newValue); + } + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + handleSave(); + } else if (e.key === 'Escape') { + setDraft(value || ''); + setEditing(false); + } + }; + + if (editing) { + const inputStyle = { + background: 'rgba(15,23,42,0.9)', + border: `1px solid ${TEAL}`, + borderRadius: '0.25rem', + color: '#CBD5E1', + fontFamily: 'monospace', + fontSize: '0.7rem', + padding: '0.25rem 0.4rem', + width: '100%', + outline: 'none', + boxShadow: `0 0 0 1px ${TEAL}40`, + }; + + return ( + setDraft(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + style={{ ...inputStyle, ...style }} + autoFocus + placeholder={placeholder} + /> + ); + } + + const displayValue = type === 'number' ? (value || 0) : (value || ''); + return ( + setEditing(true)} + style={{ + cursor: 'pointer', + display: 'inline-block', + minWidth: '30px', + minHeight: '1.2em', + padding: '0.1rem 0.25rem', + borderRadius: '0.2rem', + transition: 'background 0.15s', + ...(style || {}), + }} + onMouseEnter={e => { e.currentTarget.style.background = 'rgba(20,184,166,0.08)'; }} + onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }} + title="Click to edit" + > + {displayValue || {placeholder || '—'}} + + ); +} + // --------------------------------------------------------------------------- // Heavy Hitters Table (Task 14) // --------------------------------------------------------------------------- -function HeavyHittersTable({ heavyHitters }) { +function HeavyHittersTable({ heavyHitters, onMetadataUpdate }) { if (!heavyHitters || heavyHitters.length === 0) { return (