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.

226 lines (222 loc) 8.45 kB
'use strict'; var logger = require('../utils/logger.js'); /** * One-off migration that updates report IDs stored in Redis * from: abc123 * to: dpr_abc123 * * The migration is idempotent and will only be marked as complete once * all user configs have been successfully processed. */ class ReportIdMigrationService { redisClient; enabled; static MIGRATION_KEY = 'migration:dpr-report-ids:v1'; static USER_CONFIG_PREFIX = 'userConfig:'; static MAX_RETRIES = 3; constructor(redisClient, migrationServiceConfig) { this.redisClient = redisClient; this.enabled = migrationServiceConfig ? Boolean(migrationServiceConfig.enabled) : false; if (!this.enabled) logger.default.info(`Migration service: disabled `); this.redisClient.on('error', error => { logger.default.error(error, 'Redis error'); }); } /** * Runs the migration. */ async migrate() { if (!this.enabled) { logger.default.info('DPR report ID migration service is disabled'); return; } await this.ensureConnected(); const migrationComplete = await this.redisClient.get(ReportIdMigrationService.MIGRATION_KEY); if (migrationComplete) { logger.default.info('DPR report ID migration already completed'); return; } logger.default.info('Starting DPR report ID migration'); // 1. get all the user configs from redis const configs = await this.getAllConfigs(); let updatedConfigs = 0; const initialFailures = await Promise.all( // 2. Loop over user configs configs.map(async (config) => { try { // 3. Migrate IDs for all a users sub configs const updated = await this.migrateSingleConfig(config); if (updated) { updatedConfigs += 1; } return null; } catch (error) { logger.default.error(error, `Failed to migrate config for key ${config.key}`); // 4. return failed configs so can be retried return config; } })); // 6. Retry any failed configs - max retries = 3 const remainingFailures = await this.retryFailedConfigs(initialFailures.filter((config) => config !== null)); if (remainingFailures.length > 0) { // Dont mark as complete if there are any remaining failures logger.default.error({ failureCount: remainingFailures.length, failedKeys: remainingFailures.map(config => config.key), }, 'DPR report ID migration failed. Migration flag will not be set.'); // Leaving the flag unset allows the migration to retry on the next startup. return; } // 7. DONE Successfully - Set the completed flag globally await this.redisClient.set(ReportIdMigrationService.MIGRATION_KEY, 'true'); logger.default.info({ totalConfigs: configs.length, updatedConfigs, }, 'DPR report ID migration completed successfully'); } /** * Retries failed migrations up to the configured maximum number * of attempts. */ async retryFailedConfigs(failedConfigs, attempt = 1) { if (failedConfigs.length === 0 || attempt > ReportIdMigrationService.MAX_RETRIES) { return failedConfigs; } logger.default.warn({ attempt, remainingFailures: failedConfigs.length, }, 'Retrying failed DPR report migrations'); const retryResults = await Promise.all(failedConfigs.map(async (config) => { try { await this.migrateSingleConfig(config); return null; } catch (error) { logger.default.error(error, `Retry failed for key ${config.key}`); return config; } })); const remainingFailures = retryResults.filter((config) => config !== null); return this.retryFailedConfigs(remainingFailures, attempt + 1); } /** * Migrates a single user config and persists it if any report IDs * were updated. */ async migrateSingleConfig(configToMigrate) { const { key, config } = configToMigrate; const { updatedConfig, updated } = this.migrateConfig(config); if (!updated) { return false; } await this.redisClient.set(key, JSON.stringify(updatedConfig)); return true; } /** * Retrieves every stored report config from Redis together with its * associated Redis key. */ async getAllConfigs() { const keys = await this.getUserConfigKeys(); if (keys.length === 0) { return []; } // Fetch all user configs in a single Redis call. const values = await this.redisClient.mGet(keys); return keys.flatMap((key, index) => { const value = values[index]; if (!value) { return []; } try { return [ { key, config: JSON.parse(value), }, ]; } catch (error) { logger.default.error(error, `Failed to parse config for key ${key}`); return []; } }); } /** * Retrieves all Redis keys containing user report store data. */ async getUserConfigKeys() { const scan = async (cursor = '0', keys = []) => { const result = await this.redisClient.scan(cursor, { MATCH: `${ReportIdMigrationService.USER_CONFIG_PREFIX}*`, }); const nextKeys = [...keys, ...result.keys]; return result.cursor === '0' ? nextKeys : scan(result.cursor, nextKeys); }; return scan(); } /** * Updates all report IDs in a user config and returns the updated * config along with whether any changes were made. */ migrateConfig(config) { const requestedReports = this.updateReportIds(config.requestedReports); const recentlyViewedReports = this.updateReportIds(config.recentlyViewedReports); const bookmarks = this.updateReportIds(config.bookmarks); const downloadPermissions = this.updateReportIds(config.downloadPermissions ?? []); const defaultFilters = this.updateReportIds(config.defaultFilters ?? []); const updated = [requestedReports, recentlyViewedReports, bookmarks, downloadPermissions, defaultFilters].some(result => result.updated); return { updated, updatedConfig: { ...config, requestedReports: requestedReports.items, recentlyViewedReports: recentlyViewedReports.items, bookmarks: bookmarks.items, downloadPermissions: downloadPermissions.items, defaultFilters: defaultFilters.items, }, }; } /** * Updates report IDs in a collection and returns the updated * collection together with whether any items changed. */ updateReportIds(items) { let updated = false; const updatedItems = items.map(item => { const newId = this.getMigratedReportId(item.reportId); if (newId === item.reportId) { return item; } updated = true; return { ...item, reportId: newId, }; }); return { items: updatedItems, updated, }; } /** * Converts a legacy report ID into the new DPR-prefixed format. * * The method is intentionally idempotent so that rerunning the * migration will never double-prefix IDs. */ getMigratedReportId(reportId) { // Already migrated IDs are returned unchanged. return reportId.startsWith('dpr_') ? reportId : `dpr_${reportId}`; } async ensureConnected() { if (!this.redisClient.isOpen) { await this.redisClient.connect(); } } } exports.ReportIdMigrationService = ReportIdMigrationService; //# sourceMappingURL=reportIdMigrationService.js.map