/** * DataService - Main entry point for application data access * * ALWAYS uses Jira Assets API via CMDBService (local cache layer). * Mock data has been removed - all data must come from Jira Assets. */ import { config } from '../config/env.js'; import { cmdbService, type UpdateResult } from './cmdbService.js'; import { normalizedCacheStore as cacheStore, type CacheStats } from './normalizedCacheStore.js'; import { normalizedCacheStore } from './normalizedCacheStore.js'; import { jiraAssetsClient } from './jiraAssetsClient.js'; import { jiraAssetsService } from './jiraAssets.js'; import { logger } from './logger.js'; import type { DatabaseAdapter } from './database/interface.js'; import type { ApplicationComponent, IctGovernanceModel, ApplicationManagementDynamicsFactor, ApplicationManagementComplexityFactor, ApplicationManagementNumberOfUsers, Organisation, HostingType, BusinessImpactAnalyse, ApplicationManagementHosting, ApplicationManagementTam, ApplicationFunction, ApplicationFunctionCategory, ApplicationManagementApplicationType, BusinessImportance, ObjectReference, CMDBObject, CMDBObjectTypeName } from '../generated/jira-types.js'; import type { ApplicationDetails, ApplicationListItem, ApplicationStatus, ReferenceValue, SearchFilters, SearchResult, TeamDashboardData, TeamDashboardTeam, TeamDashboardSubteam, PlatformWithWorkloads, } from '../types/index.js'; import { calculateRequiredEffortWithMinMax } from './effortCalculation.js'; import { calculateApplicationCompleteness } from './dataCompletenessConfig.js'; // NOTE: All data comes from Jira Assets API - no mock data fallback // If schemas aren't configured yet, operations will fail gracefully with appropriate errors // ============================================================================= // Reference Cache (for enriching IDs to ObjectReferences) // ============================================================================= // Cache maps for quick ID -> ObjectReference lookups const referenceCache: Map> = new Map(); /** * Initialize or refresh reference cache for a given object type */ async function ensureReferenceCache(typeName: CMDBObjectTypeName): Promise> { if (!referenceCache.has(typeName)) { const objects = await cmdbService.getObjects(typeName); const cache = new Map(); for (const obj of objects) { cache.set(obj.id, obj); } referenceCache.set(typeName, cache); } return referenceCache.get(typeName) as Map; } /** * Lookup a reference object by ID and return as ReferenceValue */ async function lookupReference( typeName: CMDBObjectTypeName, id: number | null ): Promise { if (id === null) return null; const cache = await ensureReferenceCache(typeName); const obj = cache.get(String(id)); if (!obj) return null; return { objectId: obj.id, key: obj.objectKey, name: obj.label, }; } /** * Lookup multiple reference objects by IDs */ async function lookupReferences( typeName: CMDBObjectTypeName, ids: number[] | null | undefined ): Promise { if (!ids || ids.length === 0) return []; const cache = await ensureReferenceCache(typeName); return ids .map(id => cache.get(String(id))) .filter((obj): obj is T => obj !== undefined) .map(obj => ({ objectId: obj.id, key: obj.objectKey, name: obj.label, })); } // ============================================================================= // Helper Functions // ============================================================================= /** * Load description for an object from database * Looks for a description attribute (field_name like 'description' or attr_name like 'Description') */ async function getDescriptionFromDatabase(objectId: string): Promise { try { const { normalizedCacheStore } = await import('./normalizedCacheStore.js'); const db = (normalizedCacheStore as any).db; if (!db) return null; // Try to find description attribute by common field names const descriptionFieldNames = ['description', 'Description', 'DESCRIPTION']; // First, get the object to find its type const typedDb = db as DatabaseAdapter; const objRow = await typedDb.queryOne<{ object_type_name: string }>(` SELECT object_type_name FROM objects WHERE id = ? `, [objectId]); if (!objRow) return null; // Try each possible description field name for (const fieldName of descriptionFieldNames) { const descRow = await typedDb.queryOne<{ text_value: string }>(` SELECT av.text_value FROM attribute_values av JOIN attributes a ON av.attribute_id = a.id WHERE av.object_id = ? AND (a.field_name = ? OR a.attr_name = ?) AND av.text_value IS NOT NULL AND av.text_value != '' LIMIT 1 `, [objectId, fieldName, fieldName]); if (descRow?.text_value) { return descRow.text_value; } } return null; } catch (error) { logger.debug(`Failed to get description from database for object ${objectId}`, error); return null; } } /** * Convert ObjectReference to ReferenceValue format used by frontend * PRIMARY: Load from database cache (no API calls) * FALLBACK: Only use API if object not in database */ async function toReferenceValue(ref: ObjectReference | null | undefined): Promise { if (!ref) return null; // PRIMARY SOURCE: Try to load from database first (no API calls) try { const { normalizedCacheStore } = await import('./normalizedCacheStore.js'); const db = (normalizedCacheStore as any).db; if (db) { await db.ensureInitialized?.(); // Get basic object info from database const typedDb = db as DatabaseAdapter; const objRow = await typedDb.queryOne<{ id: string; object_key: string; label: string; }>(` SELECT id, object_key, label FROM objects WHERE id = ? OR object_key = ? LIMIT 1 `, [ref.objectId, ref.objectKey]); if (objRow) { // Object exists in database - extract description if available const description = await getDescriptionFromDatabase(objRow.id); return { objectId: objRow.id, key: objRow.object_key || ref.objectKey, name: objRow.label || ref.label, ...(description && { description }), }; } } } catch (error) { logger.debug(`Failed to load reference object ${ref.objectId} from database`, error); } // FALLBACK: Object not in database - check Jira Assets service cache // Only fetch from API if really needed (object missing from database) const enriched = jiraAssetsService.getEnrichedReferenceValue(ref.objectKey, ref.objectId); if (enriched && enriched.description) { // Use enriched value with description from service cache return enriched; } // Last resort: Object not in database and not in service cache // Only return basic info - don't fetch from API here // API fetching should only happen during sync operations if (enriched) { return enriched; } // Basic fallback - return what we have from the ObjectReference return { objectId: ref.objectId, key: ref.objectKey, name: ref.label, }; } /** * Convert array of ObjectReferences to ReferenceValue[] format */ function toReferenceValues(refs: ObjectReference[] | null | undefined): ReferenceValue[] { if (!refs || refs.length === 0) return []; return refs.map(ref => ({ objectId: ref.objectId, key: ref.objectKey, // Use label if available, otherwise fall back to objectKey, then objectId name: ref.label || ref.objectKey || ref.objectId || 'Unknown', })); } /** * Extract label from ObjectReference or return string as-is * Handles fields that may be ObjectReference or plain string */ function extractLabel(value: unknown): string | null { if (!value) return null; if (typeof value === 'string') return value; if (typeof value === 'object' && value !== null) { const obj = value as Record; // ObjectReference format if (obj.label) return String(obj.label); // User format from Jira if (obj.displayName) return String(obj.displayName); if (obj.name) return String(obj.name); } return null; } /** * Extract display value from user field or other complex types * Handles arrays and objects with displayValue property */ function extractDisplayValue(value: unknown): string | null { if (!value) return null; if (typeof value === 'string') return value; if (typeof value === 'boolean') return null; // Ignore boolean "false" values if (Array.isArray(value)) { return value.map(v => extractLabel(v)).filter(Boolean).join(', ') || null; } if (typeof value === 'object' && value !== null) { const obj = value as Record; // User format from Jira if (obj.displayValue) return String(obj.displayValue); if (obj.displayName) return String(obj.displayName); if (obj.name) return String(obj.name); if (obj.label) return String(obj.label); } return null; } /** * Convert cached ApplicationComponent to ApplicationDetails format * References are now stored as ObjectReference objects directly (not IDs) */ async function toApplicationDetails(app: ApplicationComponent): Promise { // Debug logging for confluenceSpace from cache logger.info(`[toApplicationDetails] Converting cached object ${app.objectKey || app.id} to ApplicationDetails`); logger.info(`[toApplicationDetails] confluenceSpace from cache: ${app.confluenceSpace} (type: ${typeof app.confluenceSpace})`); // Debug logging for reference fields if (process.env.NODE_ENV === 'development') { logger.debug(`[toApplicationDetails] businessOwner: ${JSON.stringify(app.businessOwner)}`); logger.debug(`[toApplicationDetails] systemOwner: ${JSON.stringify(app.systemOwner)}`); logger.debug(`[toApplicationDetails] technicalApplicationManagement: ${JSON.stringify(app.technicalApplicationManagement)}`); logger.debug(`[toApplicationDetails] supplierProduct: ${JSON.stringify(app.supplierProduct)}`); logger.debug(`[toApplicationDetails] applicationFunction: ${JSON.stringify(app.applicationFunction)}`); logger.debug(`[toApplicationDetails] applicationManagementDynamicsFactor: ${JSON.stringify(app.applicationManagementDynamicsFactor)}`); logger.debug(`[toApplicationDetails] applicationManagementComplexityFactor: ${JSON.stringify(app.applicationManagementComplexityFactor)}`); logger.debug(`[toApplicationDetails] applicationManagementNumberOfUsers: ${JSON.stringify(app.applicationManagementNumberOfUsers)}`); } // Handle confluenceSpace - it can be a string (URL) or number (legacy), convert to string const confluenceSpaceValue = app.confluenceSpace !== null && app.confluenceSpace !== undefined ? (typeof app.confluenceSpace === 'string' ? app.confluenceSpace : String(app.confluenceSpace)) : null; // Ensure factor caches are loaded for factor value lookup await ensureFactorCaches(); // Convert ObjectReference to ReferenceValue format // Fetch descriptions async if not in cache // Use Promise.all to fetch all reference values in parallel for better performance const [ governanceModel, applicationSubteam, applicationTeam, applicationType, applicationManagementHosting, applicationManagementTAM, hostingType, businessImpactAnalyse, platform, organisation, businessImportance, ] = await Promise.all([ toReferenceValue(app.ictGovernanceModel), toReferenceValue((app as any).applicationManagementSubteam), toReferenceValue((app as any).applicationManagementTeam), toReferenceValue(app.applicationManagementApplicationType), toReferenceValue(app.applicationManagementHosting), toReferenceValue(app.applicationManagementTAM), toReferenceValue(app.applicationComponentHostingType), toReferenceValue(app.businessImpactAnalyse), toReferenceValue(app.platform), toReferenceValue(app.organisation), toReferenceValue(app.businessImportance), ]); // Look up factor values from cached factor objects (same as toMinimalDetailsForEffort) // Also include descriptions from cache if available let dynamicsFactor: ReferenceValue | null = null; if (app.applicationManagementDynamicsFactor && typeof app.applicationManagementDynamicsFactor === 'object') { const factorObj = dynamicsFactorCache?.get(app.applicationManagementDynamicsFactor.objectId); dynamicsFactor = { objectId: app.applicationManagementDynamicsFactor.objectId, key: app.applicationManagementDynamicsFactor.objectKey, name: app.applicationManagementDynamicsFactor.label, factor: factorObj?.factor ?? undefined, description: factorObj?.description ?? undefined, // Include description from cache }; } let complexityFactor: ReferenceValue | null = null; if (app.applicationManagementComplexityFactor && typeof app.applicationManagementComplexityFactor === 'object') { const factorObj = complexityFactorCache?.get(app.applicationManagementComplexityFactor.objectId); complexityFactor = { objectId: app.applicationManagementComplexityFactor.objectId, key: app.applicationManagementComplexityFactor.objectKey, name: app.applicationManagementComplexityFactor.label, factor: factorObj?.factor ?? undefined, description: factorObj?.description ?? undefined, // Include description from cache }; } let numberOfUsers: ReferenceValue | null = null; if (app.applicationManagementNumberOfUsers && typeof app.applicationManagementNumberOfUsers === 'object') { const factorObj = numberOfUsersCache?.get(app.applicationManagementNumberOfUsers.objectId); numberOfUsers = { objectId: app.applicationManagementNumberOfUsers.objectId, key: app.applicationManagementNumberOfUsers.objectKey, name: app.applicationManagementNumberOfUsers.label, factor: factorObj?.factor ?? undefined, description: (factorObj as any)?.description ?? undefined, // Include description from cache (may not be in generated types) }; } // Convert array of ObjectReferences to ReferenceValue[] const applicationFunctions = toReferenceValues(app.applicationFunction); // Convert supplier fields to ReferenceValue format const [ supplierTechnical, supplierImplementation, supplierConsultancy, ] = await Promise.all([ toReferenceValue(app.supplierTechnical), toReferenceValue(app.supplierImplementation), toReferenceValue(app.supplierConsultancy), ]); // Calculate data completeness percentage // Convert ApplicationDetails-like structure to format expected by completeness calculator const appForCompleteness = { organisation: organisation?.name || null, applicationFunctions: applicationFunctions, status: (app.status || 'In Production') as ApplicationStatus, businessImpactAnalyse: businessImpactAnalyse, hostingType: hostingType, supplierProduct: extractLabel(app.supplierProduct), businessOwner: extractLabel(app.businessOwner), systemOwner: extractLabel(app.systemOwner), functionalApplicationManagement: app.functionalApplicationManagement || null, technicalApplicationManagement: extractLabel(app.technicalApplicationManagement), governanceModel: governanceModel, applicationType: applicationType, applicationManagementHosting: applicationManagementHosting, applicationManagementTAM: applicationManagementTAM, dynamicsFactor: dynamicsFactor, complexityFactor: complexityFactor, numberOfUsers: numberOfUsers, }; const completenessPercentage = calculateApplicationCompleteness(appForCompleteness); return { id: app.id, key: app.objectKey, name: app.label, description: app.description || null, status: (app.status || 'In Production') as ApplicationStatus, searchReference: app.searchReference || null, // Organization info organisation: organisation?.name || null, businessOwner: extractLabel(app.businessOwner), systemOwner: extractLabel(app.systemOwner), functionalApplicationManagement: app.functionalApplicationManagement || null, technicalApplicationManagement: extractLabel(app.technicalApplicationManagement), technicalApplicationManagementPrimary: extractDisplayValue(app.technicalApplicationManagementPrimary), technicalApplicationManagementSecondary: extractDisplayValue(app.technicalApplicationManagementSecondary), // Technical info medischeTechniek: app.medischeTechniek || false, technischeArchitectuur: app.technischeArchitectuurTA || null, supplierProduct: extractLabel(app.supplierProduct), supplierTechnical: supplierTechnical, supplierImplementation: supplierImplementation, supplierConsultancy: supplierConsultancy, // Classification applicationFunctions, businessImportance: businessImportance?.name || null, businessImpactAnalyse, hostingType, // Application Management governanceModel, applicationType, applicationSubteam, applicationTeam, dynamicsFactor, complexityFactor, numberOfUsers, applicationManagementHosting, applicationManagementTAM, platform, // Override overrideFTE: app.applicationManagementOverrideFTE ?? null, requiredEffortApplicationManagement: null, dataCompletenessPercentage: Math.round(completenessPercentage * 10) / 10, // Round to 1 decimal // Enterprise Architect reference reference: app.reference || null, // Confluence Space (URL string) confluenceSpace: confluenceSpaceValue, }; } // Pre-loaded factor caches for synchronous access let dynamicsFactorCache: Map | null = null; let complexityFactorCache: Map | null = null; let numberOfUsersCache: Map | null = null; /** * Initialize factor caches for effort calculation * Must be called before using toMinimalDetailsForEffort or toApplicationListItem */ async function ensureFactorCaches(): Promise { if (!dynamicsFactorCache) { const items = await cmdbService.getObjects('ApplicationManagementDynamicsFactor'); dynamicsFactorCache = new Map(items.map(item => [item.id, item])); } if (!complexityFactorCache) { const items = await cmdbService.getObjects('ApplicationManagementComplexityFactor'); complexityFactorCache = new Map(items.map(item => [item.id, item])); } if (!numberOfUsersCache) { const items = await cmdbService.getObjects('ApplicationManagementNumberOfUsers'); numberOfUsersCache = new Map(items.map(item => [item.id, item])); } } /** * Clear factor caches (call when cache is refreshed) */ function clearFactorCaches(): void { dynamicsFactorCache = null; complexityFactorCache = null; numberOfUsersCache = null; } /** * Create a minimal ApplicationDetails object from ApplicationComponent for effort calculation * This avoids the overhead of toApplicationDetails while providing enough data for effort calculation * Note: ensureFactorCaches() must be called before using this function */ async function toMinimalDetailsForEffort(app: ApplicationComponent): Promise { const [ governanceModel, applicationType, businessImpactAnalyse, applicationManagementHosting, ] = await Promise.all([ toReferenceValue(app.ictGovernanceModel), toReferenceValue(app.applicationManagementApplicationType), toReferenceValue(app.businessImpactAnalyse), toReferenceValue(app.applicationManagementHosting), ]); // Look up factor values from cached factor objects let dynamicsFactor: ReferenceValue | null = null; if (app.applicationManagementDynamicsFactor && typeof app.applicationManagementDynamicsFactor === 'object') { const factorObj = dynamicsFactorCache?.get(app.applicationManagementDynamicsFactor.objectId); dynamicsFactor = { objectId: app.applicationManagementDynamicsFactor.objectId, key: app.applicationManagementDynamicsFactor.objectKey, name: app.applicationManagementDynamicsFactor.label, factor: factorObj?.factor ?? undefined, }; } let complexityFactor: ReferenceValue | null = null; if (app.applicationManagementComplexityFactor && typeof app.applicationManagementComplexityFactor === 'object') { const factorObj = complexityFactorCache?.get(app.applicationManagementComplexityFactor.objectId); complexityFactor = { objectId: app.applicationManagementComplexityFactor.objectId, key: app.applicationManagementComplexityFactor.objectKey, name: app.applicationManagementComplexityFactor.label, factor: factorObj?.factor ?? undefined, }; } let numberOfUsers: ReferenceValue | null = null; if (app.applicationManagementNumberOfUsers && typeof app.applicationManagementNumberOfUsers === 'object') { const factorObj = numberOfUsersCache?.get(app.applicationManagementNumberOfUsers.objectId); numberOfUsers = { objectId: app.applicationManagementNumberOfUsers.objectId, key: app.applicationManagementNumberOfUsers.objectKey, name: app.applicationManagementNumberOfUsers.label, factor: factorObj?.factor ?? undefined, description: (factorObj as any)?.description ?? undefined, // Include description from cache (may not be in generated types) }; } return { id: app.id, key: app.objectKey, name: app.label, description: app.description || null, status: (app.status || 'In Production') as ApplicationStatus, searchReference: null, supplierProduct: null, businessOwner: null, systemOwner: null, functionalApplicationManagement: null, technicalApplicationManagement: null, technicalApplicationManagementPrimary: null, medischeTechniek: false, organisation: null, businessImpactAnalyse, businessImportance: null, governanceModel, dynamicsFactor, complexityFactor, numberOfUsers, applicationType, applicationSubteam: null, applicationTeam: null, applicationFunctions: [], hostingType: null, platform: null, applicationManagementHosting, applicationManagementTAM: null, overrideFTE: app.applicationManagementOverrideFTE ?? null, requiredEffortApplicationManagement: null, }; } /** * Convert ApplicationComponent to ApplicationListItem (lighter weight, for lists) */ async function toApplicationListItem(app: ApplicationComponent): Promise { // Use direct ObjectReference conversion instead of lookups // Fetch all reference values in parallel const [ governanceModel, dynamicsFactor, complexityFactor, applicationSubteam, applicationTeam, applicationType, platform, applicationManagementHosting, applicationManagementTAM, businessImpactAnalyse, minimalDetails, ] = await Promise.all([ toReferenceValue(app.ictGovernanceModel), toReferenceValue(app.applicationManagementDynamicsFactor), toReferenceValue(app.applicationManagementComplexityFactor), toReferenceValue((app as any).applicationManagementSubteam), toReferenceValue((app as any).applicationManagementTeam), toReferenceValue(app.applicationManagementApplicationType), toReferenceValue(app.platform), toReferenceValue(app.applicationManagementHosting), toReferenceValue(app.applicationManagementTAM), toReferenceValue(app.businessImpactAnalyse), toMinimalDetailsForEffort(app), ]); const applicationFunctions = toReferenceValues(app.applicationFunction); // Calculate effort using minimal details const effortResult = calculateRequiredEffortWithMinMax(minimalDetails); const result: ApplicationListItem = { id: app.id, key: app.objectKey, name: app.label, searchReference: app.searchReference || null, status: app.status as ApplicationStatus | null, applicationFunctions, governanceModel, dynamicsFactor, complexityFactor, applicationSubteam, applicationTeam, applicationType, platform, applicationManagementHosting, applicationManagementTAM, businessImpactAnalyse, requiredEffortApplicationManagement: effortResult.finalEffort, minFTE: effortResult.minFTE, maxFTE: effortResult.maxFTE, overrideFTE: app.applicationManagementOverrideFTE ?? null, }; // Calculate data completeness percentage // Convert ApplicationListItem to format expected by completeness calculator const [organisationRef, hostingTypeRef] = await Promise.all([ toReferenceValue(app.organisation), toReferenceValue(app.applicationComponentHostingType), ]); const appForCompleteness = { organisation: organisationRef?.name || null, applicationFunctions: result.applicationFunctions, status: result.status, businessImpactAnalyse: result.businessImpactAnalyse, hostingType: hostingTypeRef, supplierProduct: app.supplierProduct?.label || null, businessOwner: app.businessOwner?.label || null, systemOwner: app.systemOwner?.label || null, functionalApplicationManagement: app.functionalApplicationManagement || null, technicalApplicationManagement: app.technicalApplicationManagement?.label || null, governanceModel: result.governanceModel, applicationType: result.applicationType, applicationManagementHosting: result.applicationManagementHosting, applicationManagementTAM: result.applicationManagementTAM, dynamicsFactor: result.dynamicsFactor, complexityFactor: result.complexityFactor, numberOfUsers: await toReferenceValue(app.applicationManagementNumberOfUsers), }; const completenessPercentage = calculateApplicationCompleteness(appForCompleteness); return { ...result, dataCompletenessPercentage: Math.round(completenessPercentage * 10) / 10, // Round to 1 decimal }; } // ============================================================================= // Data Service // ============================================================================= export const dataService = { /** * Search applications */ async searchApplications( filters: SearchFilters, page: number = 1, pageSize: number = 25 ): Promise { // Get all applications from cache (always from Jira Assets) let apps = await cmdbService.getObjects('ApplicationComponent'); logger.debug(`DataService: Found ${apps.length} applications in cache for search`); // If cache is empty, log a warning if (apps.length === 0) { logger.warn('DataService: Cache is empty - no applications found. A full sync may be needed.'); } // Apply filters locally if (filters.searchText && filters.searchText.trim()) { const search = filters.searchText.toLowerCase().trim(); const beforeFilter = apps.length; apps = apps.filter(app => { const label = app.label?.toLowerCase() || ''; const objectKey = app.objectKey?.toLowerCase() || ''; const searchRef = app.searchReference?.toLowerCase() || ''; const description = app.description?.toLowerCase() || ''; return label.includes(search) || objectKey.includes(search) || searchRef.includes(search) || description.includes(search); }); logger.debug(`DataService: Search filter "${filters.searchText}" reduced results from ${beforeFilter} to ${apps.length}`); } if (filters.statuses && filters.statuses.length > 0) { apps = apps.filter(app => { // Handle empty/null status - treat as 'Undefined' for filtering const status = app.status || 'Undefined'; return filters.statuses!.includes(status as ApplicationStatus); }); } // Organisation filter (now ObjectReference) if (filters.organisation) { apps = apps.filter(app => { const org = app.organisation; if (!org || typeof org !== 'object') return false; return org.label === filters.organisation || org.objectKey === filters.organisation; }); } // Governance Model filter if (filters.governanceModel && filters.governanceModel !== 'all') { if (filters.governanceModel === 'filled') { apps = apps.filter(app => { const gov = app.ictGovernanceModel; return gov && typeof gov === 'object' && gov.objectId; }); } else if (filters.governanceModel === 'empty') { apps = apps.filter(app => { const gov = app.ictGovernanceModel; return !gov || typeof gov !== 'object' || !gov.objectId; }); } } // ApplicationFunction filter if (filters.applicationFunction && filters.applicationFunction !== 'all') { if (filters.applicationFunction === 'filled') { apps = apps.filter(app => { const funcs = app.applicationFunction; return Array.isArray(funcs) && funcs.length > 0; }); } else if (filters.applicationFunction === 'empty') { apps = apps.filter(app => { const funcs = app.applicationFunction; return !funcs || !Array.isArray(funcs) || funcs.length === 0; }); } } // Application Type filter if (filters.applicationType && filters.applicationType !== 'all') { if (filters.applicationType === 'filled') { apps = apps.filter(app => { const appType = app.applicationManagementApplicationType; return appType && typeof appType === 'object' && appType.objectId; }); } else if (filters.applicationType === 'empty') { apps = apps.filter(app => { const appType = app.applicationManagementApplicationType; return !appType || typeof appType !== 'object' || !appType.objectId; }); } } // Hosting Type filter (applicationComponentHostingType) if (filters.hostingType) { apps = apps.filter(app => { const hostingType = app.applicationComponentHostingType; if (!hostingType || typeof hostingType !== 'object') return false; return hostingType.label === filters.hostingType || hostingType.objectKey === filters.hostingType; }); } // Business Importance filter if (filters.businessImportance) { apps = apps.filter(app => { const importance = app.businessImportance; if (!importance || typeof importance !== 'object') return false; return importance.label === filters.businessImportance || importance.objectKey === filters.businessImportance; }); } // Application Subteam filter // Note: applicationManagementSubteam is not in generated schema, use type assertion if (filters.applicationSubteam && filters.applicationSubteam !== 'all') { if (filters.applicationSubteam === 'filled') { apps = apps.filter(app => { const subteam = (app as any).applicationManagementSubteam; return subteam && typeof subteam === 'object' && subteam.objectId; }); } else if (filters.applicationSubteam === 'empty') { apps = apps.filter(app => { const subteam = (app as any).applicationManagementSubteam; return !subteam || typeof subteam !== 'object' || !subteam.objectId; }); } else { // Specific subteam name/key apps = apps.filter(app => { const subteam = (app as any).applicationManagementSubteam; if (!subteam || typeof subteam !== 'object') return false; return subteam.label === filters.applicationSubteam || subteam.objectKey === filters.applicationSubteam; }); } } // Dynamics Factor filter if (filters.dynamicsFactor && filters.dynamicsFactor !== 'all') { if (filters.dynamicsFactor === 'filled') { apps = apps.filter(app => { const factor = app.applicationManagementDynamicsFactor; return factor && typeof factor === 'object' && factor.objectId; }); } else if (filters.dynamicsFactor === 'empty') { apps = apps.filter(app => { const factor = app.applicationManagementDynamicsFactor; return !factor || typeof factor !== 'object' || !factor.objectId; }); } } // Complexity Factor filter if (filters.complexityFactor && filters.complexityFactor !== 'all') { if (filters.complexityFactor === 'filled') { apps = apps.filter(app => { const factor = app.applicationManagementComplexityFactor; return factor && typeof factor === 'object' && factor.objectId; }); } else if (filters.complexityFactor === 'empty') { apps = apps.filter(app => { const factor = app.applicationManagementComplexityFactor; return !factor || typeof factor !== 'object' || !factor.objectId; }); } } // Paginate const total = apps.length; const startIdx = (page - 1) * pageSize; const paginatedApps = apps.slice(startIdx, startIdx + pageSize); // Ensure factor caches are loaded for effort calculation await ensureFactorCaches(); // Convert to list items (async now to fetch descriptions) const applications = await Promise.all(paginatedApps.map(toApplicationListItem)); return { applications, totalCount: total, currentPage: page, pageSize, totalPages: Math.ceil(total / pageSize), }; }, /** * Get application by ID (from cache) */ async getApplicationById(id: string): Promise { // Try to get by ID first (handles both Jira object IDs and object keys) let app = await cmdbService.getObject('ApplicationComponent', id); // If not found by ID, try by object key (e.g., "ICMT-123" or numeric IDs that might be keys) if (!app) { app = await cmdbService.getObjectByKey('ApplicationComponent', id); } if (!app) return null; return toApplicationDetails(app); }, /** * Get application for editing (force refresh from Jira) */ async getApplicationForEdit(id: string): Promise { // Try to get by ID first (handles both Jira object IDs and object keys) let app = await cmdbService.getObject('ApplicationComponent', id, { forceRefresh: true, }); // If not found by ID, try by object key (e.g., "ICMT-123" or numeric IDs that might be keys) if (!app) { app = await cmdbService.getObjectByKey('ApplicationComponent', id, { forceRefresh: true, }); } if (!app) return null; return toApplicationDetails(app); }, /** * Update application with conflict detection */ async updateApplication( id: string, updates: { applicationFunctions?: ReferenceValue[]; dynamicsFactor?: ReferenceValue; complexityFactor?: ReferenceValue; numberOfUsers?: ReferenceValue; governanceModel?: ReferenceValue; applicationSubteam?: ReferenceValue; applicationTeam?: ReferenceValue; applicationType?: ReferenceValue; hostingType?: ReferenceValue; businessImpactAnalyse?: ReferenceValue; overrideFTE?: number | null; applicationManagementHosting?: string; applicationManagementTAM?: string; }, originalUpdatedAt?: string ): Promise { logger.info(`dataService.updateApplication called for ${id}`); // Always update via Jira Assets API // Convert to CMDBService format // IMPORTANT: For reference fields, we pass ObjectReference objects (with objectKey) // because buildAttributeValues in cmdbService expects to extract objectKey for Jira API const cmdbUpdates: Record = {}; if (updates.applicationFunctions !== undefined) { // Store as array of ObjectReferences - cmdbService.buildAttributeValues will extract objectKeys cmdbUpdates.applicationFunction = updates.applicationFunctions.map(f => ({ objectId: f.objectId, objectKey: f.key, label: f.name, })); } // Map frontend field names to actual Jira field names const fieldMapping: Record = { dynamicsFactor: 'applicationManagementDynamicsFactor', complexityFactor: 'applicationManagementComplexityFactor', numberOfUsers: 'applicationManagementNumberOfUsers', governanceModel: 'ictGovernanceModel', applicationSubteam: 'applicationManagementSubteam', applicationTeam: 'applicationManagementTeam', applicationType: 'applicationManagementApplicationType', hostingType: 'applicationComponentHostingType', businessImpactAnalyse: 'businessImpactAnalyse', }; for (const [frontendField, jiraField] of Object.entries(fieldMapping)) { const value = updates[frontendField as keyof typeof updates] as ReferenceValue | undefined; if (value !== undefined) { // Store as ObjectReference - cmdbService.buildAttributeValues will extract objectKey cmdbUpdates[jiraField] = value ? { objectId: value.objectId, objectKey: value.key, label: value.name, } : null; } } if (updates.overrideFTE !== undefined) { cmdbUpdates.applicationManagementOverrideFTE = updates.overrideFTE; } if (updates.applicationManagementHosting !== undefined) { // Look up the full object to get the objectKey const hostingItems = await cmdbService.getObjects('ApplicationManagementHosting'); const match = hostingItems.find(h => h.objectKey === updates.applicationManagementHosting || h.label === updates.applicationManagementHosting); cmdbUpdates.applicationManagementHosting = match ? { objectId: match.id, objectKey: match.objectKey, label: match.label, } : null; } if (updates.applicationManagementTAM !== undefined) { // Look up the full object to get the objectKey const tamItems = await cmdbService.getObjects('ApplicationManagementTam'); const match = tamItems.find(t => t.objectKey === updates.applicationManagementTAM || t.label === updates.applicationManagementTAM); cmdbUpdates.applicationManagementTAM = match ? { objectId: match.id, objectKey: match.objectKey, label: match.label, } : null; } // If originalUpdatedAt is provided, use conflict detection let result: UpdateResult; if (originalUpdatedAt) { result = await cmdbService.updateObject('ApplicationComponent', id, cmdbUpdates, originalUpdatedAt); } else { // Force update without conflict check (legacy behavior) result = await cmdbService.forceUpdateObject('ApplicationComponent', id, cmdbUpdates); } // Invalidate team dashboard cache after successful update // This ensures the dashboard shows fresh data immediately if (result.success) { jiraAssetsService.invalidateTeamDashboardCache(); logger.info(`Invalidated team dashboard cache after updating application ${id}`); } return result; }, // =========================================================================== // Reference Data (from cache) // =========================================================================== async getDynamicsFactors(): Promise { // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationManagementDynamicsFactor'); return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label, summary: item.summary || undefined, description: item.description || undefined, factor: item.factor ?? undefined })); }, async getComplexityFactors(): Promise { // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationManagementComplexityFactor'); return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label, summary: item.summary || undefined, description: item.description || undefined, factor: item.factor ?? undefined })); }, async getNumberOfUsers(): Promise { // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationManagementNumberOfUsers'); return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label, summary: item.examples || undefined, // Use examples as summary for display factor: item.factor ?? undefined, order: item.order ?? undefined })); }, async getGovernanceModels(): Promise { // Always get from Jira Assets cache const items = await cmdbService.getObjects('IctGovernanceModel'); return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label, summary: item.summary || undefined, remarks: item.remarks || undefined, application: item.application || undefined })); }, async getOrganisations(): Promise { // Always get from Jira Assets cache const items = await cmdbService.getObjects('Organisation'); logger.debug(`DataService: Found ${items.length} organisations in cache`); return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label })); }, async getHostingTypes(): Promise { // Always get from Jira Assets cache const items = await cmdbService.getObjects('HostingType'); logger.debug(`DataService: Found ${items.length} hosting types in cache`); return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label, summary: item.description || undefined, // Use description as summary for display })); }, async getBusinessImpactAnalyses(): Promise { // Always get from Jira Assets cache const items = await cmdbService.getObjects('BusinessImpactAnalyse'); return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label, summary: item.description || undefined, // Use description as summary for display indicators: item.indicators || undefined })); }, async getApplicationManagementHosting(): Promise { // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationManagementHosting'); return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label, description: item.description || undefined, })); }, async getApplicationManagementTAM(): Promise { // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationManagementTam'); return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label, summary: item.description || undefined, // Use description as summary for display })); }, async getApplicationFunctions(): Promise { // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationFunction'); return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label, description: item.description || undefined, keywords: item.keywords || undefined, applicationFunctionCategory: item.applicationFunctionCategory ? { objectId: String(item.applicationFunctionCategory.objectId), key: item.applicationFunctionCategory.objectKey || '', name: item.applicationFunctionCategory.label || '', } : undefined, })); }, async getApplicationFunctionCategories(): Promise { // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationFunctionCategory'); return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label, description: item.description || undefined, })); }, async getApplicationSubteams(): Promise { // Always get from Jira Assets API (schema doesn't include this object type) return jiraAssetsService.getApplicationSubteams(); }, async getApplicationTeams(): Promise { // Always get from Jira Assets API (schema doesn't include this object type) return jiraAssetsService.getApplicationTeams(); }, async getSubteamToTeamMapping(): Promise> { // Always get from Jira Assets API // Convert Map to plain object for JSON serialization const mapping = await jiraAssetsService.getSubteamToTeamMapping(); const result: Record = {}; mapping.forEach((team, subteamId) => { result[subteamId] = team; }); return result; }, async getApplicationTypes(): Promise { // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationManagementApplicationType'); return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label, summary: item.description || undefined, // Use description as summary for display })); }, async getBusinessImportance(): Promise { // Always get from Jira Assets cache const items = await cmdbService.getObjects('BusinessImportance'); logger.debug(`DataService: Found ${items.length} business importance values in cache`); return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label })); }, // =========================================================================== // Dashboard / Stats // =========================================================================== async getStats(includeDistributions: boolean = true) { // Always get from Jira Assets cache const allApps = await cmdbService.getObjects('ApplicationComponent'); // Statuses to exclude for most metrics const excludedStatuses = ['Closed', 'Deprecated']; // Filter: apps excluding Closed and Deprecated const activeApps = allApps.filter(app => !excludedStatuses.includes(app.status || '')); // Totaal applicaties: Excluding Closed and Deprecated const totalApplications = activeApps.length; // Geclassificeerd: Apps where ICT Governance Model is NOT empty const classifiedCount = activeApps.filter(app => { const gov = app.ictGovernanceModel; // Check if it's an ObjectReference with a valid objectId return gov && typeof gov === 'object' && gov.objectId; }).length; // Nog te classificeren: Total - Geclassificeerd const unclassifiedCount = totalApplications - classifiedCount; // ApplicationFunction ingevuld: Apps where ApplicationFunction is NOT empty const withApplicationFunction = activeApps.filter(app => { const appFunc = app.applicationFunction; return Array.isArray(appFunc) && appFunc.length > 0; }).length; const applicationFunctionPercentage = totalApplications > 0 ? Math.round((withApplicationFunction / totalApplications) * 100) : 0; // Total ALL applications (including Closed/Deprecated) for status distribution const totalAllApplications = allApps.length; const stats = { totalApplications, // Excluding Closed/Deprecated totalAllApplications, // Including all statuses classifiedCount, unclassifiedCount, withApplicationFunction, applicationFunctionPercentage, cacheStatus: await cmdbService.getCacheStats(), }; if (includeDistributions) { // Verdeling per status: ALL applications const byStatus: Record = {}; for (const app of allApps) { const status = app.status || 'Undefined'; byStatus[status] = (byStatus[status] || 0) + 1; } // Verdeling per regiemodel: Excluding Closed and Deprecated const byGovernanceModel: Record = {}; for (const app of activeApps) { const gov = app.ictGovernanceModel; let govLabel = 'Niet ingesteld'; if (gov && typeof gov === 'object' && gov.label) { govLabel = gov.label; } byGovernanceModel[govLabel] = (byGovernanceModel[govLabel] || 0) + 1; } return { ...stats, byStatus, byGovernanceModel, // Keep old names for backwards compatibility with dashboard route governanceDistribution: byGovernanceModel, statusDistribution: byStatus, }; } return stats; }, async getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise { // Always get from Jira Assets API (has proper Team/Subteam field parsing) return jiraAssetsService.getTeamDashboardData(excludedStatuses); }, /** * Get team portfolio health metrics * Calculates average complexity, dynamics, BIA, and governance maturity per team */ async getTeamPortfolioHealth(excludedStatuses: ApplicationStatus[] = []): Promise<{ teams: Array<{ team: ReferenceValue | null; metrics: { complexity: number; // Average complexity factor (0-1 normalized) dynamics: number; // Average dynamics factor (0-1 normalized) bia: number; // Average BIA level (0-1 normalized, F=1.0, A=0.0) governanceMaturity: number; // Average governance maturity (0-1 normalized, A=1.0, E=0.0) }; applicationCount: number; }>; }> { // Always get from Jira Assets cache // Get all applications from cache to access all fields including BIA let apps = await cmdbService.getObjects('ApplicationComponent'); // Filter out excluded statuses if (excludedStatuses.length > 0) { apps = apps.filter(app => !app.status || !excludedStatuses.includes(app.status as ApplicationStatus)); } // Ensure factor caches are loaded await ensureFactorCaches(); // Helper to convert BIA letter to numeric (F=6, E=5, D=4, C=3, B=2, A=1) // Handles formats like "BIA-2024-0042 (Klasse E)" or just "E" const biaToNumeric = (bia: string | null): number | null => { if (!bia) return null; // Extract letter from patterns like "Klasse E", "E", or "(Klasse E)" const match = bia.match(/[Kk]lasse\s+([A-F])/i) || bia.match(/\b([A-F])\b/i); if (match) { const letter = match[1].toUpperCase(); const biaMap: Record = { 'F': 6, 'E': 5, 'D': 4, 'C': 3, 'B': 2, 'A': 1 }; return biaMap[letter] || null; } return null; }; // Helper to convert governance model to maturity score (A=5, B=4, C=3, D=2, E=1) const governanceToMaturity = (govModel: string | null): number | null => { if (!govModel) return null; // Extract letter from "Regiemodel X" or just "X" const match = govModel.match(/Regiemodel\s+([A-E]\+?)/i) || govModel.match(/^([A-E]\+?)$/i); if (match) { const letter = match[1].toUpperCase(); if (letter === 'A') return 5; if (letter === 'B' || letter === 'B+') return 4; if (letter === 'C') return 3; if (letter === 'D') return 2; if (letter === 'E') return 1; } return null; }; // Helper to get factor value from ReferenceValue const getFactorValue = (ref: ReferenceValue | null): number | null => { if (!ref) return null; // Look up in dynamics factors cache const dynamicsFactor = dynamicsFactorCache?.get(ref.objectId); if (dynamicsFactor?.factor !== undefined) return dynamicsFactor.factor; // Look up in complexity factors cache const complexityFactor = complexityFactorCache?.get(ref.objectId); if (complexityFactor?.factor !== undefined) return complexityFactor.factor; return null; }; // Collect all applications grouped by team const teamMetrics: Map = new Map(); // Process each application for (const app of apps) { // Get team from application (via subteam lookup if needed) let team: ReferenceValue | null = null; const applicationSubteam = await toReferenceValue((app as any).applicationManagementSubteam); const applicationTeam = await toReferenceValue((app as any).applicationManagementTeam); // Prefer direct team assignment, otherwise try to get from subteam if (applicationTeam) { team = applicationTeam; } else if (applicationSubteam) { // Look up team from subteam (would need subteam cache, but for now use subteam as fallback) team = applicationSubteam; // Fallback: use subteam if team not directly assigned } const teamKey = team?.objectId || 'unassigned'; if (!teamMetrics.has(teamKey)) { teamMetrics.set(teamKey, { team, complexityValues: [], dynamicsValues: [], biaValues: [], governanceValues: [], applicationCount: 0, }); } const metrics = teamMetrics.get(teamKey)!; metrics.applicationCount++; // Get complexity factor value if (app.applicationManagementComplexityFactor && typeof app.applicationManagementComplexityFactor === 'object') { const factorObj = complexityFactorCache?.get(app.applicationManagementComplexityFactor.objectId); if (factorObj?.factor !== undefined && factorObj.factor !== null) { metrics.complexityValues.push(factorObj.factor); } } // Get dynamics factor value if (app.applicationManagementDynamicsFactor && typeof app.applicationManagementDynamicsFactor === 'object') { const factorObj = dynamicsFactorCache?.get(app.applicationManagementDynamicsFactor.objectId); if (factorObj?.factor !== undefined && factorObj.factor !== null) { metrics.dynamicsValues.push(factorObj.factor); } } // Get BIA value if (app.businessImpactAnalyse) { const biaRef = await toReferenceValue(app.businessImpactAnalyse); if (biaRef) { const biaNum = biaToNumeric(biaRef.name); if (biaNum !== null) metrics.biaValues.push(biaNum); } } // Get governance maturity if (app.ictGovernanceModel) { const govRef = await toReferenceValue(app.ictGovernanceModel); if (govRef) { const maturity = governanceToMaturity(govRef.name); if (maturity !== null) metrics.governanceValues.push(maturity); } } } // Calculate averages and normalize to 0-1 scale const result = Array.from(teamMetrics.values()).map(metrics => { // Calculate averages const avgComplexity = metrics.complexityValues.length > 0 ? metrics.complexityValues.reduce((a, b) => a + b, 0) / metrics.complexityValues.length : 0; const avgDynamics = metrics.dynamicsValues.length > 0 ? metrics.dynamicsValues.reduce((a, b) => a + b, 0) / metrics.dynamicsValues.length : 0; const avgBIA = metrics.biaValues.length > 0 ? metrics.biaValues.reduce((a, b) => a + b, 0) / metrics.biaValues.length : 0; const avgGovernance = metrics.governanceValues.length > 0 ? metrics.governanceValues.reduce((a, b) => a + b, 0) / metrics.governanceValues.length : 0; // Normalize to 0-1 scale // Complexity and Dynamics: assume max factor is 1.0 (already normalized) // BIA: 1-6 scale -> normalize to 0-1 (1=0.0, 6=1.0) // Governance: 1-5 scale -> normalize to 0-1 (1=0.0, 5=1.0) return { team: metrics.team, metrics: { complexity: Math.min(1, Math.max(0, avgComplexity)), dynamics: Math.min(1, Math.max(0, avgDynamics)), bia: (avgBIA - 1) / 5, // (1-6) -> (0-1) governanceMaturity: (avgGovernance - 1) / 4, // (1-5) -> (0-1) }, applicationCount: metrics.applicationCount, }; }); return { teams: result }; }, // =========================================================================== // Utility // =========================================================================== async isUsingJiraAssets(): Promise { // Always returns true - mock data removed, only Jira Assets is used return true; }, async testConnection(): Promise { // Always test Jira Assets connection (requires token) if (!jiraAssetsClient.hasToken()) { return false; } return jiraAssetsClient.testConnection(); }, /** * Get cache status */ async getCacheStatus(): Promise { return await cacheStore.getStats(); }, /** * Check if cache is warm */ async isCacheWarm(): Promise { return await cacheStore.isWarm(); }, /** * Set user token for requests */ setUserToken(token: string | null): void { cmdbService.setUserToken(token); }, /** * Clear user token */ clearUserToken(): void { cmdbService.clearUserToken(); }, /** * Clear reference cache (useful after sync) */ clearReferenceCache(): void { referenceCache.clear(); clearFactorCaches(); }, // =========================================================================== // Business Importance vs BIA Comparison // =========================================================================== /** * Get Business Importance vs BIA comparison data */ async getBusinessImportanceComparison() { // Fetch all applications const allApps = await cmdbService.getObjects('ApplicationComponent'); // Fetch Business Importance reference values from CMDB const businessImportanceRefs = await this.getBusinessImportance(); // Create a map for quick lookup: name -> normalized value const biNormalizationMap = new Map(); for (const ref of businessImportanceRefs) { // Extract numeric prefix from name (e.g., "0 - Critical Infrastructure" -> 0) const match = ref.name.match(/^(\d+)\s*-/); if (match) { const numValue = parseInt(match[1], 10); // Only include 0-6, exclude 9 (Unknown) if (numValue >= 0 && numValue <= 6) { biNormalizationMap.set(ref.name, numValue); } } } const comparisonItems: Array<{ id: string; key: string; name: string; searchReference: string | null; businessImportance: string | null; businessImportanceNormalized: number | null; businessImpactAnalyse: ReferenceValue | null; biaClass: string | null; biaClassNormalized: number | null; discrepancyScore: number; discrepancyCategory: 'high_bi_low_bia' | 'low_bi_high_bia' | 'aligned' | 'missing_data'; }> = []; // Process each application directly from the objects we already have for (const app of allApps) { if (!app.id || !app.label) continue; // Extract Business Importance from app object const businessImportanceRef = await toReferenceValue(app.businessImportance); const businessImportanceName = businessImportanceRef?.name || null; // Normalize Business Importance let biNormalized: number | null = null; if (businessImportanceName) { // Try to find matching ReferenceValue const matchedRef = businessImportanceRefs.find(ref => ref.name === businessImportanceName); if (matchedRef) { biNormalized = biNormalizationMap.get(matchedRef.name) ?? null; } else { // Fallback: try to extract directly from the string const directMatch = businessImportanceName.match(/^(\d+)\s*-/); if (directMatch) { const numValue = parseInt(directMatch[1], 10); if (numValue >= 0 && numValue <= 6) { biNormalized = numValue; } } } } // Extract BIA from app object const businessImpactAnalyseRef = await toReferenceValue(app.businessImpactAnalyse); // Normalize BIA Class let biaClass: string | null = null; let biaNormalized: number | null = null; if (businessImpactAnalyseRef?.name) { // Extract class letter from name (e.g., "BIA-2024-0042 (Klasse E)" -> "E") const biaMatch = businessImpactAnalyseRef.name.match(/Klasse\s+([A-F])/i); if (biaMatch) { biaClass = biaMatch[1].toUpperCase(); // Convert to numeric: A=1, B=2, C=3, D=4, E=5, F=6 biaNormalized = biaClass.charCodeAt(0) - 64; // A=65, so 65-64=1, etc. } else { // Try to extract single letter if format is different const singleLetterMatch = businessImpactAnalyseRef.name.match(/\b([A-F])\b/i); if (singleLetterMatch) { biaClass = singleLetterMatch[1].toUpperCase(); biaNormalized = biaClass.charCodeAt(0) - 64; } } } // Calculate discrepancy let discrepancyScore = 0; let discrepancyCategory: 'high_bi_low_bia' | 'low_bi_high_bia' | 'aligned' | 'missing_data' = 'missing_data'; if (biNormalized !== null && biaNormalized !== null) { discrepancyScore = Math.abs(biNormalized - biaNormalized); // Categorize discrepancy if (biNormalized <= 2 && biaNormalized <= 2) { // High BI (0-2: Critical Infrastructure/Critical/Highest) AND Low BIA (A-B: Low impact) // IT thinks critical (0-2) but business says low impact (A-B) discrepancyCategory = 'high_bi_low_bia'; } else if (biNormalized >= 5 && biaNormalized >= 5) { // Low BI (5-6: Low/Lowest) AND High BIA (E-F: High impact) // IT thinks low priority (5-6) but business says high impact (E-F) discrepancyCategory = 'low_bi_high_bia'; } else if (discrepancyScore <= 2) { // Aligned: values are reasonably close (discrepancy ≤ 2) discrepancyCategory = 'aligned'; } else { // Medium discrepancy (3-4) - still consider aligned if not in extreme categories discrepancyCategory = 'aligned'; } } comparisonItems.push({ id: app.id, key: app.objectKey, name: app.label, searchReference: app.searchReference || null, businessImportance: businessImportanceName, businessImportanceNormalized: biNormalized, businessImpactAnalyse: businessImpactAnalyseRef, biaClass, biaClassNormalized: biaNormalized, discrepancyScore, discrepancyCategory, }); } // Calculate summary statistics const total = comparisonItems.length; const withBothFields = comparisonItems.filter(item => item.businessImportanceNormalized !== null && item.biaClassNormalized !== null ).length; const highBiLowBia = comparisonItems.filter(item => item.discrepancyCategory === 'high_bi_low_bia').length; const lowBiHighBia = comparisonItems.filter(item => item.discrepancyCategory === 'low_bi_high_bia').length; const aligned = comparisonItems.filter(item => item.discrepancyCategory === 'aligned').length; const missingData = comparisonItems.filter(item => item.discrepancyCategory === 'missing_data').length; return { applications: comparisonItems, summary: { total, withBothFields, highBiLowBia, lowBiHighBia, aligned, missingData, }, }; }, };