import { useEffect, useState } from 'react'; import { AssessorApiClient } from '../../api/assessor-api-client'; import { useApp } from '../../app/AppProvider'; import { useEnginePreferences } from '../../app/engine-preferences'; import type { FindingRecord } from '../../domain/finding'; import { gpoWorkSettingIdentity } from '../../features/results-browser/work-record-lookup'; import type { DynamicFacet } from '../../features/search-filter/dynamic-facets'; import type { RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping'; import { useRegulatoryDecisionRevision } from './decision-revision'; /** Work facets of the Compliance Engine; present only while the engine is on and its assignments are loaded. */ export const ISO_CONTROL_FACET_ID = 'compliance:iso-control'; export const ISO_THEME_FACET_ID = 'compliance:iso-theme'; export const COMPLIANCE_FACET_IDS = [ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID] as const; const SOURCE_SCAN_FACET_ID = 'provenance:source-scan'; const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological']; export interface ComplianceWorkFacetIndex { facets: [DynamicFacet, DynamicFacet]; } const indexes = new WeakMap(); /** One shared index per loaded response, so every Work consumer uses identical cache keys. */ export function complianceWorkFacetIndex(response: RegulatoryControlOverviewResponse): ComplianceWorkFacetIndex { const existing = indexes.get(response); if (existing) return existing; const byOccurrence = new Map>(); const bySetting = new Map>(); const add = (map: Map>, key: string, controlId: string) => map.set(key, (map.get(key) ?? new Set()).add(controlId)); for (const control of response.controls) { for (const finding of control.findings) { if (finding.sourceKind === 'scanner-finding') add(byOccurrence, finding.sourceResultId, control.controlId); else if (finding.sourceRuleId) add(bySetting, `${finding.policyScope ?? ''}\u0000${finding.sourceRuleId}`, control.controlId); } } // Scanner findings match by occurrence; GPO settings by scope and technical setting identity, like Result DB drill-downs. const controlsFor = (record: FindingRecord): string[] => { if (record.findingOccurrenceId) return [...byOccurrence.get(record.findingOccurrenceId) ?? []]; const identity = gpoWorkSettingIdentity(record.id); return identity ? [...bySetting.get(`${identity.scope ?? ''}\u0000${identity.settingId}`) ?? []] : []; }; const themeOf = new Map(response.controls.map(control => [control.controlId, control.theme])); const controls = [...response.controls].sort((left, right) => left.controlId.localeCompare(right.controlId, 'en', { numeric: true })); const index: ComplianceWorkFacetIndex = { facets: [{ id: ISO_CONTROL_FACET_ID, label: 'ISO control', source: 'derived', kind: 'category', description: 'Compliance Engine: findings and GPO settings with this accepted ISO/IEC 27002 control. It does not change results.', values: controls.map(control => ({ value: control.controlId, label: `${control.annexAControlId}${control.title ? ` ยท ${control.title}` : ''}`, count: 0 })), valuesFor: controlsFor }, { id: ISO_THEME_FACET_ID, label: 'ISO theme', source: 'derived', kind: 'category', description: 'Compliance Engine: findings and GPO settings with an accepted ISO/IEC 27002 control of this theme. It does not change results.', // Facet values are matched in lower case; the label keeps the ISO spelling. values: THEMES.filter(theme => response.controls.some(control => control.theme === theme)) .map(theme => ({ value: theme.toLowerCase(), label: theme, count: 0 })), valuesFor: record => [...new Set(controlsFor(record).flatMap(controlId => themeOf.get(controlId)?.toLowerCase() ?? []))] }] }; indexes.set(response, index); return index; } /** Adds the ISO facets directly before the permanent final `Source: Scan` control. */ export function withComplianceFacets(facets: DynamicFacet[], index: ComplianceWorkFacetIndex | undefined): DynamicFacet[] { if (!index) return facets; const sourceScan = facets.findIndex(facet => facet.id === SOURCE_SCAN_FACET_ID); return sourceScan < 0 ? [...facets, ...index.facets] : [...facets.slice(0, sourceScan), ...index.facets, ...facets.slice(sourceScan)]; } const assessorApi = new AssessorApiClient(); // One request per timeslot and decision revision, shared by every Work consumer. const requests = new Map>(); const ready = new Map(); const lastReadyByScope = new Map(); /** The ISO facet index for Work, or undefined while the engine is off or nothing is loaded yet. */ export function useComplianceWorkFacets(): ComplianceWorkFacetIndex | undefined { const app = useApp(); const available = useEnginePreferences().compliance; const revision = useRegulatoryDecisionRevision(); const assessmentIds = [...(app.activeAssessmentIds ?? [])].sort(); const scanRunIds = [...(app.activeScanRunIds ?? [])].sort(); const projectId = app.targetProjectId; const scope = JSON.stringify([projectId, assessmentIds, scanRunIds]); const key = `${scope}\u0000${revision}`; const enabled = available && app.backendStatus === 'connected' && Boolean(projectId) && (assessmentIds.length > 0 || scanRunIds.length > 0); const [, setLoaded] = useState(0); useEffect(() => { if (!enabled || !projectId || ready.has(key)) return; let active = true; let request = requests.get(key); if (!request) { request = assessorApi.getRegulatoryControlAssignments(projectId, { assessmentIds, scanRunIds }); requests.set(key, request); request.then(response => { ready.set(key, response); lastReadyByScope.set(scope, response); while (ready.size > 8) ready.delete(ready.keys().next().value!); while (lastReadyByScope.size > 4) lastReadyByScope.delete(lastReadyByScope.keys().next().value!); }, () => undefined).finally(() => requests.delete(key)); } request.then(() => { if (active) setLoaded(value => value + 1); }, () => undefined); return () => { active = false; }; }, [enabled, key]); // eslint-disable-line react-hooks/exhaustive-deps if (!enabled) return undefined; // While a new decision revision of the same timeslot loads, the last loaded state keeps an active filter valid. const response = ready.get(key) ?? lastReadyByScope.get(scope); return response ? complianceWorkFacetIndex(response) : undefined; } /** Test support. */ export function resetComplianceWorkFacetsForTests(): void { requests.clear(); ready.clear(); lastReadyByScope.clear(); }