import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { BackendStatus } from '../../app/app-state'; import type { FindingRecord } from '../../domain/finding'; import { AssessorApiClient } from '../../api/assessor-api-client'; import { useEnginePreferences } from '../../app/engine-preferences'; import { mappingRunKey, subscribeRegulatoryMappingRuns, trackRegulatoryMappingRun } from './mapping-run-tracker'; import { markRegulatoryDecisionSaved } from './decision-revision'; import { acceptedControlIds, type RegulatoryMappingCandidate, type RegulatoryMappingDecisionValue, type RegulatoryMappingResponse } from '../regulatory-mapping'; /** An accepted control as the Finding Reader names it. */ export interface AcceptedRegulatoryControl { controlId: string; annexAControlId: string; title?: string; } const assessorApi = new AssessorApiClient(); export function useRegulatoryMapping( projectId: string, /** Result DB row of the record: a scanner finding occurrence or a GPO baseline comparison. */ resultProjectionId: string, backendStatus: BackendStatus ) { // A switched-off Compliance Engine shows no control, opens nothing and sends no request. const available = useEnginePreferences().compliance; const [requestedOpen, setOpen] = useState(false); const open = requestedOpen && available; // Re-enabling the engine starts with the window closed again. useEffect(() => { if (!available) setOpen(false); }, [available]); const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: RegulatoryMappingResponse }>(); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const scopeKey = `${projectId}\u0000${resultProjectionId}\u0000${backendStatus}`; const activeScope = useRef({ key: scopeKey }); if (activeScope.current.key !== scopeKey) activeScope.current = { key: scopeKey }; const requestScope = activeScope.current; const response = storedResponse?.scope === requestScope ? storedResponse.value : undefined; useEffect(() => { setOpen(false); setStoredResponse(undefined); setLoading(false); setError(''); }, [scopeKey]); const load = useCallback(async () => { if (!projectId || !resultProjectionId || backendStatus !== 'connected' || activeScope.current !== requestScope) return; setLoading(true); try { const next = await assessorApi.getRegulatoryMappings(projectId, resultProjectionId); trackRegulatoryMappingRun(projectId, resultProjectionId, next); if (activeScope.current !== requestScope) return; setStoredResponse({ scope: requestScope, value: next }); setError(''); } catch (reason) { if (activeScope.current !== requestScope) return; setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be loaded.'); } finally { if (activeScope.current === requestScope) setLoading(false); } }, [backendStatus, projectId, requestScope, resultProjectionId]); // Loaded with the finding, so the reader can name its accepted controls. A running mapping is polled // once by the run tracker, which keeps its activity entry until the backend ends it. useEffect(() => { if (available && !response && !loading && !error) void load(); }, [available, error, load, loading, response]); useEffect(() => subscribeRegulatoryMappingRuns((key, next) => { if (key === mappingRunKey(projectId, resultProjectionId) && activeScope.current === requestScope) setStoredResponse({ scope: requestScope, value: next }); }), [projectId, requestScope, resultProjectionId]); const start = useCallback(async () => { if (!projectId || !resultProjectionId || activeScope.current !== requestScope) return; setLoading(true); try { const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId }); trackRegulatoryMappingRun(projectId, resultProjectionId, next); if (activeScope.current !== requestScope) return; setStoredResponse({ scope: requestScope, value: next }); setError(''); } catch (reason) { if (activeScope.current !== requestScope) return; setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be started.'); } finally { if (activeScope.current === requestScope) setLoading(false); } }, [projectId, requestScope, resultProjectionId]); const decide = useCallback(async (candidate: RegulatoryMappingCandidate, decision: RegulatoryMappingDecisionValue, comment?: string) => { if (!projectId || !resultProjectionId || !response?.run || activeScope.current !== requestScope) return; const latest = response.decisions.find(item => item.controlId === candidate.controlId); setLoading(true); try { const next = await assessorApi.saveRegulatoryMappingDecision(projectId, { resultProjectionId, controlId: candidate.controlId, decision, ...(comment?.trim() ? { comment: comment.trim() } : {}), ...(latest ? { expectedLatestDecisionId: latest.id } : {}) }); markRegulatoryDecisionSaved(); if (activeScope.current !== requestScope) return; setStoredResponse({ scope: requestScope, value: next }); setError(''); } catch (reason) { if (activeScope.current !== requestScope) return; setError(reason instanceof Error ? reason.message : 'The mapping decision could not be saved.'); } finally { if (activeScope.current === requestScope) setLoading(false); } }, [projectId, requestScope, response, resultProjectionId]); const acceptedControls = useMemo(() => available && response ? acceptedControlIds(response.decisions).map(controlId => { const candidate = response.run?.candidates.find(item => item.controlId === controlId); return { controlId, annexAControlId: candidate?.annexAControlId ?? `A.${controlId}`, ...(candidate?.title ? { title: candidate.title } : {}) }; }).sort((left, right) => left.controlId.localeCompare(right.controlId, 'en', { numeric: true })) : [], [available, response]); const localDisabledReason = !resultProjectionId ? 'This record has no stored finding or current GPO baseline comparison.' : backendStatus !== 'connected' ? 'The backend is not connected.' : response && !response.generationEnabled ? response.disabledReason ?? 'Regulatory mapping is unavailable.' : undefined; return { available, open, setOpen, response, loading, error, start, decide, acceptedControls, acceptedCount: acceptedControls.length, disabledReason: localDisabledReason }; } /** Names the stored Result DB row an ISO mapping is keyed from; empty when the record has none. */ export function regulatoryMappingResultId(record: Pick): string { if (record.findingOccurrenceId) return `scanner-finding:${record.findingOccurrenceId}`; return record.policyComparisonId ? `policy-evaluation-comparison:${record.policyComparisonId}` : ''; }