- Add DatabaseAdapter type imports where needed - Properly type database adapter calls with type assertions - Fix type mismatches in schemaMappingService - Fix ensureInitialized calls on DatabaseAdapter
1678 lines
64 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
},
|
|
};
|