Assessor_2.0/src/compliance/ui/use-regulatory-mapping.ts

157 lines
7.8 KiB
TypeScript

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 { useOptionalApp } from '../../app/AppProvider';
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;
// The shown timeslot is where a single mapping comes from; the mapping history names it relative to its view.
const app = useOptionalApp();
const origin = useRef({ assessmentIds: [] as string[], scanRunIds: [] as string[] });
origin.current = { assessmentIds: [...(app?.activeAssessmentIds ?? [])], scanRunIds: [...(app?.activeScanRunIds ?? [])] };
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, origin: origin.current });
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: Pick<RegulatoryMappingCandidate, 'controlId'>, 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<AcceptedRegulatoryControl[]>(() => available && response
? acceptedControlIds(response.decisions).map(controlId => {
// A control decided in an earlier run keeps its title after the current run stopped retrieving it.
const candidate = response.run?.candidates.find(item => item.controlId === controlId)
?? response.earlierControls?.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,
/** BSI requirements derived from the accepted controls; undefined while the engine is off or none is derived. */
bsi: available ? response?.bsi : undefined,
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<FindingRecord, 'findingOccurrenceId' | 'policyComparisonId'>): string {
if (record.findingOccurrenceId) return `scanner-finding:${record.findingOccurrenceId}`;
return record.policyComparisonId ? `policy-evaluation-comparison:${record.policyComparisonId}` : '';
}