// 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, }; } /** * Deduplicates devices by hostname, keeping the earliest non-null resolution_date. * A device appearing in multiple metrics counts once. * * @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} items * @returns {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} */ function deduplicateByHostname(items) { const map = {}; for (const item of items) { const key = item.hostname; if (!map[key]) { map[key] = { hostname: item.hostname, resolution_date: item.resolution_date || null, vertical: item.vertical }; } else { // Keep the earliest non-null resolution_date const existing = map[key]; if (item.resolution_date != null) { if (existing.resolution_date == null || item.resolution_date < existing.resolution_date) { existing.resolution_date = item.resolution_date; } } } } return Object.values(map); } /** * Computes aggregated burndown from a deduplicated array of device objects. * Each device has { hostname, resolution_date, vertical }. * * @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} devices * @returns {{ * total: number, * blockers: number, * with_dates: number, * monthly: Object, * projection: Object, * projected_clear_date: string|null, * by_vertical: Array<{ vertical: string, total: number, blockers: number, with_dates: number }> * }} */ function computeAggregatedBurndown(devices) { const total = devices.length; const withDates = devices.filter(d => d.resolution_date != null); const blockerDevices = devices.filter(d => d.resolution_date == null); const blockers = blockerDevices.length; const with_dates = withDates.length; // Bucket by month (YYYY-MM) const monthly = {}; for (const device of withDates) { const dateStr = typeof device.resolution_date === 'string' ? device.resolution_date : device.resolution_date.toISOString().slice(0, 10); const month = dateStr.slice(0, 7); monthly[month] = (monthly[month] || 0) + 1; } // Sort monthly keys chronologically const sortedMonths = Object.keys(monthly).sort(); const sortedMonthly = {}; for (const m of sortedMonths) { sortedMonthly[m] = monthly[m]; } // Cumulative projection let remaining = total; const projection = {}; for (const month of sortedMonths) { remaining -= sortedMonthly[month]; projection[month] = { remediated: sortedMonthly[month], remaining }; } // Projected clear date let projected_clear_date = null; if (blockers === 0 && sortedMonths.length > 0) { projected_clear_date = sortedMonths[sortedMonths.length - 1]; } // Per-vertical breakdown const verticalMap = {}; for (const device of devices) { const v = device.vertical; if (!verticalMap[v]) { verticalMap[v] = { vertical: v, total: 0, blockers: 0, with_dates: 0 }; } verticalMap[v].total++; if (device.resolution_date == null) { verticalMap[v].blockers++; } else { verticalMap[v].with_dates++; } } // Sort descending by total, filter out zero-total entries const by_vertical = Object.values(verticalMap) .filter(v => v.total > 0) .sort((a, b) => b.total - a.total); return { total, blockers, with_dates, monthly: sortedMonthly, projection, projected_clear_date, by_vertical, }; } /** * Computes per-metric forecast burndown from device records and historical snapshots. * * Pure function — no side effects, no database access. Suitable for property-based testing. * * @param {Array<{hostname: string, resolution_date: string|null}>} currentDevices * Active non-compliant devices for the metric * @param {number} totalAssets * Total device count in scope for this metric (from snapshot or summary) * @param {Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>} historicalSnapshots * Pre-computed historical data points (up to 4 months) * @returns {{ * historical: Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>, * forecast: Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>, * current_snapshot: {total_assets: number, non_compliant: number, compliant: number, compliance_pct: number, blockers: number, with_dates: number} * }} */ function computeMetricForecastBurndown(currentDevices, totalAssets, historicalSnapshots) { // Compute compliance_pct helper function calcCompliancePct(total, nc) { if (total === 0) return 0; return Math.round(((total - nc) / total) * 1000) / 10; } // Historical — pass through as-is const historical = (historicalSnapshots || []).map(snap => ({ month: snap.month, total_assets: snap.total_assets, non_compliant: snap.non_compliant, compliance_pct: snap.compliance_pct, })); // Requirement 3.7: empty currentDevices → empty forecast, zeroed snapshot except total_assets if (!currentDevices || currentDevices.length === 0) { return { historical, forecast: [], current_snapshot: { total_assets: totalAssets, non_compliant: 0, compliant: 0, compliance_pct: 0, blockers: 0, with_dates: 0, }, }; } const nonCompliant = currentDevices.length; // Partition devices into blockers (no resolution_date) and with_dates const blockers = currentDevices.filter(d => d.resolution_date == null).length; const withDates = nonCompliant - blockers; // Current snapshot const compliant = totalAssets - nonCompliant; const currentCompliancePct = calcCompliancePct(totalAssets, nonCompliant); const current_snapshot = { total_assets: totalAssets, non_compliant: nonCompliant, compliant: compliant, compliance_pct: currentCompliancePct, blockers: blockers, with_dates: withDates, }; // If no devices have resolution dates, return empty forecast if (withDates === 0) { return { historical, forecast: [], current_snapshot }; } // Determine current month (YYYY-MM) const now = new Date(); const currentYear = now.getFullYear(); const currentMonth = now.getMonth(); // 0-indexed function formatMonth(year, month) { return `${year}-${String(month + 1).padStart(2, '0')}`; } const currentMonthStr = formatMonth(currentYear, currentMonth); // Bucket devices with resolution dates by their resolution month // Past-due dates (month before current month) are treated as remediated in current month const buckets = {}; for (const device of currentDevices) { if (device.resolution_date == null) continue; const resMonth = device.resolution_date.slice(0, 7); // YYYY-MM if (resMonth < currentMonthStr) { // Past-due: treat as remediated in current month buckets[currentMonthStr] = (buckets[currentMonthStr] || 0) + 1; } else { buckets[resMonth] = (buckets[resMonth] || 0) + 1; } } // Generate forecast months starting from current month, up to 12 months max const forecast = []; let remainingNonCompliant = nonCompliant; for (let i = 0; i < 12; i++) { const forecastYear = currentYear + Math.floor((currentMonth + i) / 12); const forecastMonth = (currentMonth + i) % 12; const monthStr = formatMonth(forecastYear, forecastMonth); // Decrement by devices remediated in this month if (buckets[monthStr]) { remainingNonCompliant -= buckets[monthStr]; } const pct = calcCompliancePct(totalAssets, remainingNonCompliant); forecast.push({ month: monthStr, total_assets: totalAssets, non_compliant: remainingNonCompliant, compliance_pct: pct, }); // Terminate early if all dated devices are remediated (only blockers remain) if (remainingNonCompliant <= blockers) { break; } } return { historical, forecast, current_snapshot }; } module.exports = { truncateText, validateRemediationPlan, isValidDateString, formatPct, computeVCLStats, categorizeNonCompliant, rankHeavyHitters, computeForecastBurndown, matchByHostname, computeBulkDiff, mapColumnHeaders, parseVerticalFilename, computeVerticalBurndown, deduplicateByHostname, computeAggregatedBurndown, computeMetricForecastBurndown, };