Files
cmdb-insight/backend/src/services/dataService.ts
Bert Hausmans 9ad4bd9a73 Fix remaining TypeScript 'Untyped function calls' errors
- Add DatabaseAdapter type imports where needed
- Properly type database adapter calls with type assertions
- Fix type mismatches in schemaMappingService
- Fix ensureInitialized calls on DatabaseAdapter
2026-01-21 09:39:58 +01:00

1678 lines
64 KiB
TypeScript

/**
* 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<string, Map<string, CMDBObject>> = new Map();
/**
* Initialize or refresh reference cache for a given object type
*/
async function ensureReferenceCache<T extends CMDBObject>(typeName: CMDBObjectTypeName): Promise<Map<string, T>> {
if (!referenceCache.has(typeName)) {
const objects = await cmdbService.getObjects<T>(typeName);
const cache = new Map<string, CMDBObject>();
for (const obj of objects) {
cache.set(obj.id, obj);
}
referenceCache.set(typeName, cache);
}
return referenceCache.get(typeName) as Map<string, T>;
}
/**
* Lookup a reference object by ID and return as ReferenceValue
*/
async function lookupReference<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
id: number | null
): Promise<ReferenceValue | null> {
if (id === null) return null;
const cache = await ensureReferenceCache<T>(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<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
ids: number[] | null | undefined
): Promise<ReferenceValue[]> {
if (!ids || ids.length === 0) return [];
const cache = await ensureReferenceCache<T>(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<string | null> {
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<ReferenceValue | null> {
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<string, unknown>;
// 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<string, unknown>;
// 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<ApplicationDetails> {
// 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<string, ApplicationManagementDynamicsFactor> | null = null;
let complexityFactorCache: Map<string, ApplicationManagementComplexityFactor> | null = null;
let numberOfUsersCache: Map<string, ApplicationManagementNumberOfUsers> | null = null;
/**
* Initialize factor caches for effort calculation
* Must be called before using toMinimalDetailsForEffort or toApplicationListItem
*/
async function ensureFactorCaches(): Promise<void> {
if (!dynamicsFactorCache) {
const items = await cmdbService.getObjects<ApplicationManagementDynamicsFactor>('ApplicationManagementDynamicsFactor');
dynamicsFactorCache = new Map(items.map(item => [item.id, item]));
}
if (!complexityFactorCache) {
const items = await cmdbService.getObjects<ApplicationManagementComplexityFactor>('ApplicationManagementComplexityFactor');
complexityFactorCache = new Map(items.map(item => [item.id, item]));
}
if (!numberOfUsersCache) {
const items = await cmdbService.getObjects<ApplicationManagementNumberOfUsers>('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<ApplicationDetails> {
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<ApplicationListItem> {
// 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<SearchResult> {
// Get all applications from cache (always from Jira Assets)
let apps = await cmdbService.getObjects<ApplicationComponent>('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<ApplicationDetails | null> {
// Try to get by ID first (handles both Jira object IDs and object keys)
let app = await cmdbService.getObject<ApplicationComponent>('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>('ApplicationComponent', id);
}
if (!app) return null;
return toApplicationDetails(app);
},
/**
* Get application for editing (force refresh from Jira)
*/
async getApplicationForEdit(id: string): Promise<ApplicationDetails | null> {
// Try to get by ID first (handles both Jira object IDs and object keys)
let app = await cmdbService.getObject<ApplicationComponent>('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>('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<UpdateResult> {
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<string, unknown> = {};
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<string, string> = {
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>('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>('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<ReferenceValue[]> {
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationManagementDynamicsFactor>('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<ReferenceValue[]> {
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationManagementComplexityFactor>('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<ReferenceValue[]> {
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationManagementNumberOfUsers>('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<ReferenceValue[]> {
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<IctGovernanceModel>('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<ReferenceValue[]> {
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<Organisation>('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<ReferenceValue[]> {
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<HostingType>('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<ReferenceValue[]> {
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<BusinessImpactAnalyse>('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<ReferenceValue[]> {
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationManagementHosting>('ApplicationManagementHosting');
return items.map(item => ({
objectId: item.id,
key: item.objectKey,
name: item.label,
description: item.description || undefined,
}));
},
async getApplicationManagementTAM(): Promise<ReferenceValue[]> {
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationManagementTam>('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<ReferenceValue[]> {
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationFunction>('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<ReferenceValue[]> {
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationFunctionCategory>('ApplicationFunctionCategory');
return items.map(item => ({
objectId: item.id,
key: item.objectKey,
name: item.label,
description: item.description || undefined,
}));
},
async getApplicationSubteams(): Promise<ReferenceValue[]> {
// Always get from Jira Assets API (schema doesn't include this object type)
return jiraAssetsService.getApplicationSubteams();
},
async getApplicationTeams(): Promise<ReferenceValue[]> {
// Always get from Jira Assets API (schema doesn't include this object type)
return jiraAssetsService.getApplicationTeams();
},
async getSubteamToTeamMapping(): Promise<Record<string, ReferenceValue | null>> {
// Always get from Jira Assets API
// Convert Map to plain object for JSON serialization
const mapping = await jiraAssetsService.getSubteamToTeamMapping();
const result: Record<string, ReferenceValue | null> = {};
mapping.forEach((team, subteamId) => {
result[subteamId] = team;
});
return result;
},
async getApplicationTypes(): Promise<ReferenceValue[]> {
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationManagementApplicationType>('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<ReferenceValue[]> {
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<BusinessImportance>('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>('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<string, number> = {};
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<string, number> = {};
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<TeamDashboardData> {
// 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>('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<string, number> = { '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<string, {
team: ReferenceValue | null;
complexityValues: number[];
dynamicsValues: number[];
biaValues: number[];
governanceValues: number[];
applicationCount: number;
}> = 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<boolean> {
// Always returns true - mock data removed, only Jira Assets is used
return true;
},
async testConnection(): Promise<boolean> {
// Always test Jira Assets connection (requires token)
if (!jiraAssetsClient.hasToken()) {
return false;
}
return jiraAssetsClient.testConnection();
},
/**
* Get cache status
*/
async getCacheStatus(): Promise<CacheStats> {
return await cacheStore.getStats();
},
/**
* Check if cache is warm
*/
async isCacheWarm(): Promise<boolean> {
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>('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<string, number>();
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,
},
};
},
};