// Pure helper functions for VCL Compliance Reporting // No database dependencies — all functions are stateless and testable in isolation. /** * Truncates text to maxLen characters with an ellipsis. * Returns '' for null/undefined input. */ function truncateText(text, maxLen = 80) { if (text == null) return ''; if (text.length <= maxLen) return text; return text.slice(0, maxLen) + '\u2026'; } /** * Validates that a remediation plan does not exceed 2000 characters. * Null/undefined/empty values are considered valid (no plan documented). */ function validateRemediationPlan(text) { if (text == null || text === '') return { valid: true }; if (text.length > 2000) return { valid: false, error: 'Remediation plan exceeds 2000 characters' }; return { valid: true }; } /** * Returns true only for strings parseable as real calendar dates. * Rejects null, undefined, empty string, and invalid dates like "2026-02-30". */ function isValidDateString(str) { if (str == null || str === '') return false; if (typeof str !== 'string') return false; // Expect YYYY-MM-DD format const match = str.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!match) return false; const year = parseInt(match[1], 10); const month = parseInt(match[2], 10); const day = parseInt(match[3], 10); // Month must be 1-12 if (month < 1 || month > 12) return false; // Create date and verify components match (catches invalid days like Feb 30) const date = new Date(year, month - 1, day); return ( date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day ); } /** * Formats a decimal as a whole-number percentage string. * Returns '0%' for null, undefined, or NaN input. */ function formatPct(decimal) { if (decimal == null || isNaN(decimal)) return '0%'; return Math.round(decimal * 100) + '%'; } /** * Computes VCL summary statistics from an array of device objects. * Each item should have at least { is_compliant: boolean, in_scope: boolean }. */ function computeVCLStats(items, targetPct) { const total = items.length; const in_scope = items.filter(item => item.in_scope).length; const compliant = items.filter(item => item.is_compliant).length; const non_compliant = in_scope - compliant; const remediations_required = non_compliant; const compliance_pct = in_scope > 0 ? Math.round((compliant / in_scope) * 100) : 0; return { total, in_scope, compliant, non_compliant, remediations_required, compliance_pct, target_pct: targetPct, }; } /** * Partitions non-compliant items into "blocked" (no resolution_date) and * "in_progress" (resolution_date set). Returns counts and percentages. */ function categorizeNonCompliant(items) { const total = items.length; const blocked = items.filter(item => item.resolution_date == null); const in_progress = items.filter(item => item.resolution_date != null); return { blocked: { count: blocked.length, pct: total > 0 ? Math.round((blocked.length / total) * 100) : 0, }, in_progress: { count: in_progress.length, pct: total > 0 ? Math.round((in_progress.length / total) * 100) : 0, }, }; } /** * Sorts verticals by non_compliant count in descending order. * Returns a new sorted array (does not mutate input). */ function rankHeavyHitters(verticalData) { return [...verticalData].sort((a, b) => b.non_compliant - a.non_compliant); } /** * Buckets non-compliant items by resolution_date month (YYYY-MM). * Items with null resolution_date are skipped. * Returns an object like { '2026-05': 3, '2026-06': 7 }. */ function computeForecastBurndown(items) { const buckets = {}; for (const item of items) { if (item.resolution_date == null) continue; const dateStr = typeof item.resolution_date === 'string' ? item.resolution_date : item.resolution_date.toISOString().slice(0, 10); const month = dateStr.slice(0, 7); // YYYY-MM buckets[month] = (buckets[month] || 0) + 1; } return buckets; } /** * Matches uploaded rows to existing hostnames. * Returns { matched: [...], unmatched: [...] }. */ function matchByHostname(uploadedRows, existingHostnames) { const matched = []; const unmatched = []; for (const row of uploadedRows) { if (existingHostnames.has(row.hostname)) { matched.push(row); } else { unmatched.push(row); } } return { matched, unmatched }; } /** * Compares uploaded row values against current DB values. * currentData is a Map of hostname -> { resolution_date, remediation_plan, notes }. * Returns array of { hostname, status: 'changed'|'unchanged', fields: { fieldName: { old, new } } }. */ function computeBulkDiff(matchedRows, currentData) { const results = []; const COMPARE_FIELDS = ['resolution_date', 'remediation_plan', 'notes']; for (const row of matchedRows) { const current = currentData.get(row.hostname) || {}; const fields = {}; let hasChange = false; for (const field of COMPARE_FIELDS) { if (field in row) { const oldVal = current[field] != null ? current[field] : null; const newVal = row[field] != null ? row[field] : null; if (oldVal !== newVal) { fields[field] = { old: oldVal, new: newVal }; hasChange = true; } } } results.push({ hostname: row.hostname, status: hasChange ? 'changed' : 'unchanged', fields, }); } return results; } /** * Maps column header strings to known field names (case-insensitive). * Returns a mapping object like { hostname: 0, resolution_date: 3 } where values are column indices. */ function mapColumnHeaders(headers) { const mapping = {}; const KNOWN_MAPPINGS = { hostname: 'hostname', 'resolution date': 'resolution_date', resolution_date: 'resolution_date', 'remediation plan': 'remediation_plan', remediation_plan: 'remediation_plan', notes: 'notes', }; for (let i = 0; i < headers.length; i++) { const normalized = headers[i].trim().toLowerCase(); if (KNOWN_MAPPINGS[normalized]) { mapping[KNOWN_MAPPINGS[normalized]] = i; } } return mapping; } /** * Extracts vertical code and report date from a filename. * Pattern: _YYYY_MM_DD.xlsx * The vertical is everything before the trailing _YYYY_MM_DD portion. * * Examples: * NTS_AEO_2026_05_11.xlsx → { vertical: 'NTS_AEO', date: '2026-05-11' } * SDIT_CISO_2026_05_11.xlsx → { vertical: 'SDIT_CISO', date: '2026-05-11' } * SR_2026_05_11.xlsx → { vertical: 'SR', date: '2026-05-11' } * AllOthers_2026_05_11.xlsx → { vertical: 'AllOthers', date: '2026-05-11' } * * Returns null if the filename does not match the expected pattern. */ function parseVerticalFilename(filename) { // Strip .xlsx extension (case-insensitive) const stem = filename.replace(/\.xlsx$/i, ''); // Match: everything up to the last _YYYY_MM_DD const match = stem.match(/^(.+?)_(\d{4})_(\d{2})_(\d{2})$/); if (!match) return null; const vertical = match[1]; const date = `${match[2]}-${match[3]}-${match[4]}`; // Validate the date portion is a real date if (!isValidDateString(date)) return null; return { vertical, date }; } /** * Computes per-vertical burndown forecast from non-compliant items. * Returns breakdown of items with/without resolution dates and monthly projections. */ function computeVerticalBurndown(items) { const total = items.length; const withDates = items.filter(i => i.resolution_date != null); const blockers = items.filter(i => i.resolution_date == null); // Bucket by month const monthly = {}; for (const item of withDates) { const dateStr = typeof item.resolution_date === 'string' ? item.resolution_date : item.resolution_date.toISOString().slice(0, 10); const month = dateStr.slice(0, 7); // YYYY-MM monthly[month] = (monthly[month] || 0) + 1; } // Cumulative projection — how many remain after each month let remaining = total; const projection = {}; for (const month of Object.keys(monthly).sort()) { remaining -= monthly[month]; projection[month] = { remediated: monthly[month], remaining }; } // Projected clear date — first month where remaining hits 0 (excluding blockers) let projectedClearDate = null; if (blockers.length === 0 && Object.keys(projection).length > 0) { const sortedMonths = Object.keys(projection).sort(); projectedClearDate = sortedMonths[sortedMonths.length - 1]; } return { total, blockers: blockers.length, with_dates: withDates.length, monthly, projection, projected_clear_date: projectedClearDate, }; } module.exports = { truncateText, validateRemediationPlan, isValidDateString, formatPct, computeVCLStats, categorizeNonCompliant, rankHeavyHitters, computeForecastBurndown, matchByHostname, computeBulkDiff, mapColumnHeaders, parseVerticalFilename, computeVerticalBurndown, };