UNPKG

@ministryofjustice/hmpps-digital-prison-reporting-frontend

Version:

The Digital Prison Reporting Frontend contains templates and code to help display data effectively in UI applications.

393 lines (390 loc) 13.2 kB
import { getMyReport } from '../../routes/journeys/my-reports/utils.js'; import { RequestStatus, FIFTEEN_MINUTES_MS, TERMINAL_STATUSES } from './types.js'; import { ErrorHandler } from '../ErrorHandler/ErrorHandler.js'; import { ReportType } from '../../types/UserReports.js'; import { getValues } from '../localsHelper.js'; import { getAllMyReports } from '../reportStoreHelper.js'; /** * ------------------------------------------------------------ * API * ------------------------------------------------------------ */ /** * Get the status by report type * * @param {GetReportStatusOptions} { * reportType, * services, * token, * reportId, * id, * executionId, * definitionsPath, * tableId, * } * @return {*} {Promise<UpstreamSignal>} */ async function getStatusByType({ reportType, services, token, reportId, id, executionId, definitionsPath, tableId, }) { try { const response = reportType === ReportType.DASHBOARD ? await services.dashboardService.getAsyncStatus(token, reportId, id, executionId, tableId, definitionsPath) : await services.reportingService.getAsyncReportStatus(token, reportId, id, executionId, definitionsPath, tableId); const data = response; if (!data.status) { return { kind: 'EMPTY' }; } if (data.status === RequestStatus.FAILED) { const formatted = failedPayloadToDprError(data); return { kind: 'ERROR', failure: toFailureInfo(formatted), }; } return { kind: 'STATUS', status: data.status, }; } catch (error) { const formatted = new ErrorHandler(error).formatError(); return { kind: 'ERROR', failure: toFailureInfo(formatted), }; } } /** * ------------------------------------------------------------ * ORCHESTRATION * ------------------------------------------------------------ */ /** * Gets the status for a reports and its child reports * * @param {{ * stored: StoredReportData * services: Services * token: string * }} { * stored, * services, * token, * } * @return {*} {Promise<{ * parentSignal: UpstreamSignal * childSignals: UpstreamSignal[] * }>} */ async function getStatus({ stored, services, token, }) { const { reportId, id, tableId, executionId, dataProductDefinitionsPath } = stored; const definitionsPath = dataProductDefinitionsPath || ''; if (!executionId || !tableId) { throw new Error('Stored report missing executionId or tableId'); } const parentSignal = await getStatusByType({ reportType: stored.type, services, token, reportId, id, executionId, definitionsPath, tableId, }); const childSignals = stored.childExecutionData ? await Promise.all(stored.childExecutionData.map(child => { const { tableId: childTableId, executionId: childExecutionId } = child; if (!childExecutionId || !childTableId) { throw new Error('Stored child report missing executionId or tableId'); } return getStatusByType({ services, reportType: ReportType.REPORT, token, reportId, id, executionId: childExecutionId, definitionsPath, tableId: childTableId, }); })) : []; return { parentSignal, childSignals, }; } /** * Converts a normalized internal error (`DprErrorMessage`) into a `FailureInfo` * object that is safe and consistent to store and return to clients. * * @param {DprErrorMessage} error * @return {*} {FailureInfo} */ function toFailureInfo(error) { const normalize = (msg) => Array.isArray(msg) ? msg.join('. ') : msg || 'An unexpected error occurred'; return { userMessage: normalize(error.userMessage), ...(error.developerMessage && { developerMessage: normalize(error.developerMessage), }), ...(error.moreInfo && { moreInfo: error.moreInfo }), ...(typeof error.status === 'number' && { errorCode: error.status, }), }; } /** * Adapts a successful (200 OK) response that represents a semantic * failure (`status: FAILED`) into the standard `DprErrorMessage` shape. * * @param {*} raw * @return {*} {DprErrorMessage} */ function failedPayloadToDprError(raw) { const { error, stateChangeReason } = raw; return { userMessage: 'An unexpected error occurred', developerMessage: error, ...(stateChangeReason && { moreInfo: stateChangeReason }), }; } /** * ------------------------------------------------------------ * RESOLVER * ------------------------------------------------------------ */ function isTerminal(status) { return status ? TERMINAL_STATUSES.has(status) : false; } /** * Decides the authoritative status of a report. * * @export * @param {{ * stored: StoredReportData * parentSignal: UpstreamSignal * childSignals: UpstreamSignal[] * now: number * }} { * stored, * parentSignal, * childSignals, * now, * } * @return {*} {StatusResolution} */ function resolveReportStatus({ stored, parentSignal, childSignals, now, }) { const currentStatus = stored.status ?? RequestStatus.SUBMITTED; /** * ------------------------------------------------------------ * TERMINAL GUARD * ------------------------------------------------------------ * Once a report is terminal, it never changes again. */ if (isTerminal(currentStatus)) { return { type: 'NO_CHANGE' }; } /** * ------------------------------------------------------------ * TIMEOUT * ------------------------------------------------------------ * If polling exceeds 15 minutes, we decide it has failed. */ const requestedAt = stored.timestamp?.requested; if (requestedAt) { const requestedTime = new Date(requestedAt).getTime(); if (now - requestedTime >= FIFTEEN_MINUTES_MS) { return { type: 'UPDATE', newStatus: RequestStatus.FAILED, failureInfo: { userMessage: 'Report generation timed out', developerMessage: 'Polling exceeded 15 minutes', }, }; } } /** * ------------------------------------------------------------ * API ERROR * ------------------------------------------------------------ * The API failed. */ if (parentSignal.kind === 'ERROR' || childSignals.some(signal => signal.kind === 'ERROR')) { const failure = parentSignal.kind === 'ERROR' ? parentSignal.failure : childSignals.find(s => s.kind === 'ERROR').failure; return { type: 'UPDATE', newStatus: RequestStatus.FAILED, failureInfo: failure, }; } /** * ------------------------------------------------------------ * CHILD AGGREGATION * ------------------------------------------------------------ * Execution identity comes from stored.childExecutionData, * execution truth comes from childSignals. */ const childStatuses = childSignals.filter(s => s.kind === 'STATUS').map(s => s.status); if (childStatuses.some(status => status === RequestStatus.FAILED)) { return { type: 'UPDATE', newStatus: RequestStatus.FAILED, }; } if (childStatuses.length > 0 && childStatuses.every(status => status === RequestStatus.FINISHED)) { return { type: 'UPDATE', newStatus: RequestStatus.FINISHED, }; } /** * ------------------------------------------------------------ * NORMAL STATUS TRANSITION * ------------------------------------------------------------ * Parent progressed (SUBMITTED → STARTED → PICKED → FINISHED) */ if (parentSignal.kind === 'STATUS' && parentSignal.status !== currentStatus) { return { type: 'UPDATE', newStatus: parentSignal.status, }; } /** * ------------------------------------------------------------ * NOTHING CHANGED * ------------------------------------------------------------ */ return { type: 'NO_CHANGE' }; } /** * Evaluates the current authoritative report status. */ async function evaluateReportStatus({ stored, services, token, now = Date.now(), }) { // 1. Get the status data. const { parentSignal, childSignals } = await getStatus({ stored, services, token, }); // 2. Apply and resolve status rules. return resolveReportStatus({ stored, parentSignal, childSignals, now, }); } /** * Ensures the stored report status is correct. */ async function evaluateAndUpdateReportStatus({ stored, services, token, res, }) { const { dprUser } = getValues(res); const { executionId } = stored; if (!executionId) { throw new Error('Stored report missing executionId'); } const resolution = await evaluateReportStatus({ stored, services, token, }); if (resolution.type === 'NO_CHANGE') { return { resolution }; } // Set the error message if any to a string const errorMessage = resolution.failureInfo ? JSON.stringify(resolution.failureInfo) : undefined; // Update the status await services.requestedReportService.updateStatus(executionId, dprUser.id, resolution.newStatus, errorMessage); // Get the updated stored state const updated = await getMyReport({ executionId }, 'requestedReports', services, dprUser.id); if (!updated) { return { resolution }; } return { resolution, updated, }; } /** * ------------------------------------------------------------ * PREFLIGHT / INITIALISATION * ------------------------------------------------------------ */ async function detectExpiredFinishedReports({ reports, services, token, }) { const finishedWithTables = reports.filter(r => (r.status === RequestStatus.FINISHED || r.status === RequestStatus.READY) && Boolean(r.executionId) && Boolean(r.tableId)); if (finishedWithTables.length === 0) { return []; } // de‑duplicate tableIds for batch lookup const tableIds = [...new Set(finishedWithTables.map(r => r.tableId))]; const batches = chunkArray(tableIds, 200); const expiryStates = await batches.reduce(async (accPromise, batch) => { const acc = await accPromise; const result = await services.reportingService.getTableExpiryState(token, batch); return [...acc, ...result]; }, Promise.resolve([])); const expiredTableIds = new Set(expiryStates.filter(s => s.expired).map(s => s.tableId)); return finishedWithTables.filter(r => expiredTableIds.has(r.tableId)).map(r => ({ executionId: r.executionId })); } const chunkArray = (arr, size) => Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => arr.slice(i * size, i * size + size)); /** * Checks the expired status of reports and updates the state * * @export * @param {ExpireFinishedReportsOptions} { * reports, * services, * token, * res, * } * @return {*} {Promise<StoredReportData[]>} */ async function expireFinishedReports({ requestedReports, recentlyViewedReports, services, res, }) { const { dprUser, token } = getValues(res); const reports = [...requestedReports, ...recentlyViewedReports]; const expired = await detectExpiredFinishedReports({ reports, services, token, }); if (expired.length === 0) { return { requestedReports, recentlyViewedReports, }; } // de-duplicate executionIds const uniqueExpired = [...new Map(expired.map(e => [e.executionId, e])).values()]; // If any expired then update the state await uniqueExpired.reduce(async (prev, { executionId }) => { await prev; await services.requestedReportService.setToExpired(executionId, dprUser.id); await services.recentlyViewedService.setToExpired(executionId, dprUser.id); }, Promise.resolve()); // get a fresh version of all reports return getAllMyReports(res, services, dprUser.id); } const EXPIRED_CHECK_INTERVAL_MS = 30 * 60 * 1000; // 30 /** * Checks if the expiry check should run * * @export * @param {*} session * @return {*} {boolean} */ function shouldRunExpiryCheck(session) { const lastRun = session.lastExpiredReportsCheckAt; if (!lastRun) return true; return Date.now() - lastRun > EXPIRED_CHECK_INTERVAL_MS; } function recordExpiryCheck(session) { // eslint-disable-next-line no-param-reassign session.lastExpiredReportsCheckAt = Date.now(); } export { evaluateAndUpdateReportStatus, expireFinishedReports, recordExpiryCheck, shouldRunExpiryCheck }; //# sourceMappingURL=getReportStatus.js.map