Files
cmdb-insight/backend/src/routes/applications.ts
Bert Hausmans cdee0e8819 UI styling improvements: dashboard headers and navigation
- Restore blue PageHeader on Dashboard (/app-components)
- Update homepage (/) with subtle header design without blue bar
- Add uniform PageHeader styling to application edit page
- Fix Rapporten link on homepage to point to /reports overview
- Improve header descriptions spacing for better readability
2026-01-21 03:24:56 +01:00

788 lines
32 KiB
TypeScript

import { Router, Request, Response } from 'express';
import { dataService } from '../services/dataService.js';
import { databaseService } from '../services/database.js';
import { cmdbService } from '../services/cmdbService.js';
import { logger } from '../services/logger.js';
import { calculateRequiredEffortApplicationManagementWithBreakdown } from '../services/effortCalculation.js';
import { findBIAMatch, loadBIAData, clearBIACache, calculateSimilarity } from '../services/biaMatchingService.js';
import { calculateApplicationCompleteness } from '../services/dataCompletenessConfig.js';
import { getQueryString, getParamString } from '../utils/queryHelpers.js';
import { requireAuth, requirePermission } from '../middleware/authorization.js';
import type { SearchFilters, ReferenceValue, ClassificationResult, ApplicationDetails, ApplicationStatus } from '../types/index.js';
import type { Server, Flows, Certificate, Domain, AzureSubscription, CMDBObjectTypeName } from '../generated/jira-types.js';
const router = Router();
// All routes require authentication
router.use(requireAuth);
// Search applications with filters (requires search permission)
router.post('/search', requirePermission('search'), async (req: Request, res: Response) => {
try {
const { filters, page = 1, pageSize = 25 } = req.body as {
filters: SearchFilters;
page?: number;
pageSize?: number;
};
const result = await dataService.searchApplications(filters, page, pageSize);
res.json(result);
} catch (error) {
logger.error('Failed to search applications', error);
res.status(500).json({ error: 'Failed to search applications' });
}
});
// Get team dashboard data
router.get('/team-dashboard', async (req: Request, res: Response) => {
try {
const excludedStatusesParam = getQueryString(req, 'excludedStatuses');
let excludedStatuses: ApplicationStatus[] = [];
if (excludedStatusesParam && excludedStatusesParam.trim().length > 0) {
// Parse comma-separated statuses
excludedStatuses = excludedStatusesParam
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0) as ApplicationStatus[];
} else {
// Default to excluding 'Closed' and 'Deprecated' if not specified
excludedStatuses = ['Closed', 'Deprecated'];
}
const data = await dataService.getTeamDashboardData(excludedStatuses);
res.json(data);
} catch (error) {
logger.error('Failed to get team dashboard data', error);
res.status(500).json({ error: 'Failed to get team dashboard data' });
}
});
// Get team portfolio health metrics
router.get('/team-portfolio-health', async (req: Request, res: Response) => {
try {
const excludedStatusesParam = getQueryString(req, 'excludedStatuses');
let excludedStatuses: ApplicationStatus[] = [];
if (excludedStatusesParam && excludedStatusesParam.trim().length > 0) {
// Parse comma-separated statuses
excludedStatuses = excludedStatusesParam
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0) as ApplicationStatus[];
} else {
// Default to excluding 'Closed' and 'Deprecated' if not specified
excludedStatuses = ['Closed', 'Deprecated'];
}
const data = await dataService.getTeamPortfolioHealth(excludedStatuses);
res.json(data);
} catch (error) {
logger.error('Failed to get team portfolio health', error);
res.status(500).json({ error: 'Failed to get team portfolio health' });
}
});
// Get Business Importance vs BIA comparison
// NOTE: This must come BEFORE the /:id route to avoid route conflicts
router.get('/business-importance-comparison', async (req: Request, res: Response) => {
try {
logger.info('Business Importance comparison endpoint called');
const data = await dataService.getBusinessImportanceComparison();
logger.info(`Business Importance comparison: returning ${data.applications.length} applications`);
res.json(data);
} catch (error) {
logger.error('Failed to get business importance comparison', error);
res.status(500).json({ error: 'Failed to get business importance comparison' });
}
});
// Test BIA data loading (for debugging)
router.get('/bia-test', async (req: Request, res: Response) => {
try {
if (getQueryString(req, 'clear') === 'true') {
clearBIACache();
}
const biaData = await loadBIAData();
res.json({
recordCount: biaData.length,
records: biaData.slice(0, 20), // First 20 records
sample: biaData.slice(0, 5).map(r => `${r.applicationName} -> ${r.biaValue}`),
});
} catch (error) {
logger.error('Failed to test BIA data loading', error);
res.status(500).json({ error: 'Failed to test BIA data loading', details: error instanceof Error ? error.message : String(error) });
}
});
// Comprehensive debug endpoint for BIA matching
router.get('/bia-debug', async (req: Request, res: Response) => {
try {
clearBIACache();
const biaData = await loadBIAData();
// Get a few sample applications
const searchResult = await dataService.searchApplications({}, 1, 50);
const sampleApps = searchResult.applications.slice(0, 10);
// Test specific examples mentioned by user
const testNames = ['Aanmeldzuilen', 'PregnaOne', 'BeagleBoxx'];
const testApps = searchResult.applications.filter(app =>
testNames.some(name =>
app.name.toLowerCase().includes(name.toLowerCase()) ||
(app.searchReference && app.searchReference.toLowerCase().includes(name.toLowerCase()))
)
);
const debugResults = [];
// Test each sample app
for (const app of [...sampleApps, ...testApps]) {
const matchResult = await findBIAMatch(app.name, app.searchReference ?? null);
// Find all potential matches in Excel data for detailed analysis
const normalizedAppName = app.name.toLowerCase().trim();
const normalizedSearchRef = app.searchReference ? app.searchReference.toLowerCase().trim() : null;
const potentialMatches = biaData.map(record => {
const normalizedRecordName = record.applicationName.toLowerCase().trim();
const exactNameMatch = normalizedAppName === normalizedRecordName;
const exactSearchRefMatch = normalizedSearchRef === normalizedRecordName;
const startsWithApp = normalizedRecordName.startsWith(normalizedAppName) || normalizedAppName.startsWith(normalizedRecordName);
const containsApp = normalizedRecordName.includes(normalizedAppName) || normalizedAppName.includes(normalizedRecordName);
// Calculate similarity using the exported function
const similarity = calculateSimilarity(normalizedAppName, normalizedRecordName);
return {
excelName: record.applicationName,
excelBIA: record.biaValue,
exactNameMatch,
exactSearchRefMatch,
startsWithApp,
containsApp,
similarity: similarity,
};
}).filter(m => m.exactNameMatch || m.exactSearchRefMatch || m.startsWithApp || m.containsApp || m.similarity > 0.5);
debugResults.push({
appName: app.name,
searchReference: app.searchReference,
matchResult: matchResult,
potentialMatches: potentialMatches.slice(0, 5), // Top 5 potential matches
allExcelRecords: biaData.length,
});
}
res.json({
excelDataLoaded: biaData.length,
sampleExcelRecords: biaData.slice(0, 20),
debugResults,
testNames: testNames.map(name => {
const matchingExcel = biaData.filter(r =>
r.applicationName.toLowerCase().includes(name.toLowerCase()) ||
name.toLowerCase().includes(r.applicationName.toLowerCase())
);
return {
name,
matchingExcelRecords: matchingExcel,
};
}),
});
} catch (error) {
logger.error('Failed to debug BIA matching', error);
res.status(500).json({
error: 'Failed to debug BIA matching',
details: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
}
});
// Get BIA comparison data
router.get('/bia-comparison', async (req: Request, res: Response) => {
try {
// Clear cache and reload BIA data to ensure fresh data
const { loadBIAData, clearBIACache } = await import('../services/biaMatchingService.js');
clearBIACache();
// Load fresh data
const testBIAData = await loadBIAData();
logger.info(`BIA comparison: Loaded ${testBIAData.length} records from Excel file`);
if (testBIAData.length === 0) {
logger.error('BIA comparison: No Excel data loaded - check if BIA.xlsx exists and is readable');
logger.error('This could indicate:');
logger.error(' 1. Excel file not found at backend/data/BIA.xlsx');
logger.error(' 2. Column C (index 2) or K (index 10) not found');
logger.error(' 3. No valid BIA values (A-F) in column K');
} else {
logger.info(`BIA comparison: Sample Excel records: ${testBIAData.slice(0, 5).map(r => `"${r.applicationName}"->${r.biaValue}`).join(', ')}`);
}
// Get all applications (no filters)
const searchResult = await dataService.searchApplications({}, 1, 10000);
logger.info(`BIA comparison: Found ${searchResult.applications.length} applications to compare`);
// Get all Business Impact Analyses for mapping
const biaReferences = await dataService.getBusinessImpactAnalyses();
const biaMap = new Map<string, ReferenceValue>();
biaReferences.forEach(bia => {
// Map by the letter (A-F) - typically the name starts with the letter
const letter = bia.name.charAt(0).toUpperCase();
if (/^[A-F]$/.test(letter)) {
biaMap.set(letter, bia);
}
// Also try to match by key or name containing the letter
if (bia.name.toUpperCase().includes(' - ')) {
const parts = bia.name.split(' - ');
if (parts.length > 0 && /^[A-F]$/.test(parts[0].trim().toUpperCase())) {
biaMap.set(parts[0].trim().toUpperCase(), bia);
}
}
});
const applications = searchResult.applications;
const comparisonItems = [];
let matched = 0;
let mismatched = 0;
let notFound = 0;
let noExcelBIA = 0;
for (const app of applications) {
// Find BIA match in Excel
const matchResult = await findBIAMatch(app.name, app.searchReference ?? null);
// Log first few matches for debugging
if (comparisonItems.length < 5) {
logger.debug(`BIA match for "${app.name}": ${matchResult.biaValue || 'NOT FOUND'} (type: ${matchResult.matchType || 'none'})`);
}
// Extract BIA letter from current BIA name (handle formats like "A", "A - Test/Archief", etc.)
let currentBIALetter: string | null = null;
if (app.businessImpactAnalyse?.name) {
const match = app.businessImpactAnalyse.name.match(/^([A-F])/);
if (match) {
currentBIALetter = match[1].toUpperCase();
} else {
// Fallback to first character
currentBIALetter = app.businessImpactAnalyse.name.charAt(0).toUpperCase();
if (!/^[A-F]$/.test(currentBIALetter)) {
currentBIALetter = null;
}
}
}
const excelBIALetter = matchResult.biaValue;
let matchStatus: 'match' | 'mismatch' | 'not_found' | 'no_excel_bia';
if (!excelBIALetter) {
matchStatus = 'no_excel_bia';
noExcelBIA++;
} else if (!currentBIALetter) {
matchStatus = 'not_found';
notFound++;
} else if (currentBIALetter === excelBIALetter) {
matchStatus = 'match';
matched++;
} else {
matchStatus = 'mismatch';
mismatched++;
}
comparisonItems.push({
id: app.id,
key: app.key,
name: app.name,
searchReference: app.searchReference,
currentBIA: app.businessImpactAnalyse,
excelBIA: excelBIALetter,
excelApplicationName: matchResult.excelApplicationName,
matchStatus,
matchType: matchResult.matchType,
matchConfidence: matchResult.matchConfidence,
allMatches: matchResult.allMatches,
});
}
res.json({
applications: comparisonItems,
summary: {
total: applications.length,
matched,
mismatched,
notFound,
noExcelBIA,
},
});
} catch (error) {
logger.error('Failed to get BIA comparison', error);
res.status(500).json({ error: 'Failed to get BIA comparison' });
}
});
// Get application by ID
// Query params:
// - mode=edit: Force refresh from Jira for editing (includes _jiraUpdatedAt for conflict detection)
router.get('/:id', async (req: Request, res: Response) => {
const id = getParamString(req, 'id');
try {
const mode = getQueryString(req, 'mode');
// Don't treat special routes as application IDs
if (id === 'team-dashboard' || id === 'team-portfolio-health' || id === 'business-importance-comparison' || id === 'bia-comparison' || id === 'bia-test' || id === 'calculate-effort' || id === 'search') {
res.status(404).json({ error: 'Route not found' });
return;
}
// Edit mode: force refresh from Jira for fresh data + conflict detection
const application = mode === 'edit'
? await dataService.getApplicationForEdit(id)
: await dataService.getApplicationById(id);
if (!application) {
res.status(404).json({ error: 'Application not found', id });
return;
}
// Calculate data completeness percentage
const completenessPercentage = calculateApplicationCompleteness(application);
const applicationWithCompleteness = {
...application,
dataCompletenessPercentage: Math.round(completenessPercentage * 10) / 10, // Round to 1 decimal
};
res.json(applicationWithCompleteness);
} catch (error) {
logger.error(`Failed to get application ${id}`, error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const errorDetails = error instanceof Error && error.stack ? error.stack : String(error);
logger.debug(`Error details for application ${id}:`, errorDetails);
res.status(500).json({
error: 'Failed to get application',
details: errorMessage,
id: id,
});
}
});
// Update application with conflict detection (requires edit permission)
router.put('/:id', requirePermission('edit_applications'), async (req: Request, res: Response) => {
try {
// Check if user has Jira PAT configured OR service account token is available (required for write operations)
const userSettings = (req as any).userSettings;
const { config } = await import('../config/env.js');
// Allow writes if user has PAT OR service account token is configured
if (!userSettings?.jira_pat && !config.jiraServiceAccountToken) {
res.status(403).json({
error: 'Jira PAT not configured',
message: 'A Personal Access Token (PAT) is required to save changes to Jira Assets. Please configure it in your user settings, or configure JIRA_SERVICE_ACCOUNT_TOKEN in .env as a fallback.'
});
return;
}
const id = getParamString(req, 'id');
const { updates, _jiraUpdatedAt } = req.body as {
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;
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
};
_jiraUpdatedAt?: string;
};
// Support both new format (updates object) and legacy format (direct body)
const actualUpdates = updates || req.body;
const application = await dataService.getApplicationById(id);
if (!application) {
res.status(404).json({ error: 'Application not found' });
return;
}
// Build changes object for history
const changes: ClassificationResult['changes'] = {};
if (actualUpdates.applicationFunctions) {
changes.applicationFunctions = {
from: application.applicationFunctions,
to: actualUpdates.applicationFunctions,
};
}
if (actualUpdates.dynamicsFactor) {
changes.dynamicsFactor = {
from: application.dynamicsFactor,
to: actualUpdates.dynamicsFactor,
};
}
if (actualUpdates.complexityFactor) {
changes.complexityFactor = {
from: application.complexityFactor,
to: actualUpdates.complexityFactor,
};
}
if (actualUpdates.numberOfUsers) {
changes.numberOfUsers = {
from: application.numberOfUsers,
to: actualUpdates.numberOfUsers,
};
}
if (actualUpdates.governanceModel) {
changes.governanceModel = {
from: application.governanceModel,
to: actualUpdates.governanceModel,
};
}
// Call updateApplication with conflict detection if _jiraUpdatedAt is provided
const result = await dataService.updateApplication(id, actualUpdates, _jiraUpdatedAt);
// Check for conflicts
if (!result.success && result.conflict) {
// Return 409 Conflict with details
res.status(409).json({
status: 'conflict',
message: 'Object is gewijzigd door iemand anders',
conflicts: result.conflict.conflicts,
jiraUpdatedAt: result.conflict.jiraUpdatedAt,
canMerge: result.conflict.canMerge,
warning: result.conflict.warning,
actions: {
forceOverwrite: true,
merge: result.conflict.canMerge || false,
discard: true,
},
});
return;
}
if (!result.success) {
res.status(500).json({ error: result.error || 'Failed to update application' });
return;
}
// Save to classification history
const classificationResult: ClassificationResult = {
applicationId: id,
applicationName: application.name,
changes,
source: actualUpdates.source || 'MANUAL',
timestamp: new Date(),
};
await databaseService.saveClassificationResult(classificationResult);
// Return updated application
const updatedApp = result.data || await dataService.getApplicationById(id);
res.json(updatedApp);
} catch (error) {
logger.error('Failed to update application', error);
res.status(500).json({ error: 'Failed to update application' });
}
});
// Force update (ignore conflicts) (requires edit permission)
router.put('/:id/force', requirePermission('edit_applications'), async (req: Request, res: Response) => {
try {
// Check if user has Jira PAT configured OR service account token is available (required for write operations)
const userSettings = (req as any).userSettings;
const { config } = await import('../config/env.js');
// Allow writes if user has PAT OR service account token is configured
if (!userSettings?.jira_pat && !config.jiraServiceAccountToken) {
res.status(403).json({
error: 'Jira PAT not configured',
message: 'A Personal Access Token (PAT) is required to save changes to Jira Assets. Please configure it in your user settings, or configure JIRA_SERVICE_ACCOUNT_TOKEN in .env as a fallback.'
});
return;
}
const id = getParamString(req, 'id');
const updates = req.body;
const application = await dataService.getApplicationById(id);
if (!application) {
res.status(404).json({ error: 'Application not found' });
return;
}
// Force update without conflict check
const result = await dataService.updateApplication(id, updates);
if (!result.success) {
res.status(500).json({ error: result.error || 'Failed to update application' });
return;
}
const updatedApp = result.data || await dataService.getApplicationById(id);
res.json(updatedApp);
} catch (error) {
logger.error('Failed to force update application', error);
res.status(500).json({ error: 'Failed to force update application' });
}
});
// Calculate FTE effort for an application (real-time calculation without saving)
router.post('/calculate-effort', async (req: Request, res: Response) => {
try {
const applicationData = req.body as Partial<ApplicationDetails>;
// Build a complete ApplicationDetails object with defaults
const application: ApplicationDetails = {
id: applicationData.id || '',
key: applicationData.key || '',
name: applicationData.name || '',
searchReference: applicationData.searchReference || null,
description: applicationData.description || null,
supplierProduct: applicationData.supplierProduct || null,
organisation: applicationData.organisation || null,
hostingType: applicationData.hostingType || null,
status: applicationData.status || null,
businessImportance: applicationData.businessImportance || null,
businessImpactAnalyse: applicationData.businessImpactAnalyse || null,
systemOwner: applicationData.systemOwner || null,
businessOwner: applicationData.businessOwner || null,
functionalApplicationManagement: applicationData.functionalApplicationManagement || null,
technicalApplicationManagement: applicationData.technicalApplicationManagement || null,
technicalApplicationManagementPrimary: applicationData.technicalApplicationManagementPrimary || null,
technicalApplicationManagementSecondary: applicationData.technicalApplicationManagementSecondary || null,
medischeTechniek: applicationData.medischeTechniek || false,
applicationFunctions: applicationData.applicationFunctions || [],
dynamicsFactor: applicationData.dynamicsFactor || null,
complexityFactor: applicationData.complexityFactor || null,
numberOfUsers: applicationData.numberOfUsers || null,
governanceModel: applicationData.governanceModel || null,
applicationSubteam: applicationData.applicationSubteam || null,
applicationTeam: applicationData.applicationTeam || null,
applicationType: applicationData.applicationType || null,
platform: applicationData.platform || null,
requiredEffortApplicationManagement: null,
overrideFTE: applicationData.overrideFTE || null,
applicationManagementHosting: applicationData.applicationManagementHosting || null,
applicationManagementTAM: applicationData.applicationManagementTAM || null,
technischeArchitectuur: applicationData.technischeArchitectuur || null,
};
const result = calculateRequiredEffortApplicationManagementWithBreakdown(application);
res.json({
requiredEffortApplicationManagement: result.finalEffort,
breakdown: result.breakdown,
});
} catch (error) {
logger.error('Failed to calculate effort', error);
res.status(500).json({ error: 'Failed to calculate effort' });
}
});
// Get application classification history
router.get('/:id/history', async (req: Request, res: Response) => {
try {
const id = getParamString(req, 'id');
const history = await databaseService.getClassificationsByApplicationId(id);
res.json(history);
} catch (error) {
logger.error('Failed to get classification history', error);
res.status(500).json({ error: 'Failed to get classification history' });
}
});
// Get related objects for an application (from cache)
router.get('/:id/related/:objectType', async (req: Request, res: Response) => {
try {
const id = getParamString(req, 'id');
const objectType = getParamString(req, 'objectType');
// Map object type string to CMDBObjectTypeName
const typeMap: Record<string, CMDBObjectTypeName> = {
'Server': 'Server',
'server': 'Server',
'Flows': 'Flows',
'flows': 'Flows',
'Flow': 'Flows',
'flow': 'Flows',
'Connection': 'Flows', // Frontend uses "Connection" for Flows
'connection': 'Flows',
'Certificate': 'Certificate',
'certificate': 'Certificate',
'Domain': 'Domain',
'domain': 'Domain',
'AzureSubscription': 'AzureSubscription',
'azuresubscription': 'AzureSubscription',
};
const typeName = typeMap[objectType];
if (!typeName) {
res.status(400).json({ error: `Unknown object type: ${objectType}` });
return;
}
// Use CMDBService to get related objects from cache
type RelatedObjectType = Server | Flows | Certificate | Domain | AzureSubscription;
let relatedObjects: RelatedObjectType[] = [];
// Get requested attributes from query string (needed for fallback)
const attributesParam = getQueryString(req, 'attributes');
const requestedAttrs = attributesParam
? attributesParam.split(',').map(a => a.trim())
: [];
logger.debug(`Getting related objects for application ${id}, objectType: ${objectType}, typeName: ${typeName}, requestedAttrs: ${requestedAttrs.join(',') || 'none'}`);
// First try to get from cache
switch (typeName) {
case 'Server':
relatedObjects = await cmdbService.getReferencingObjects<Server>(id, 'Server');
logger.debug(`Found ${relatedObjects.length} Servers referencing application ${id} in cache`);
break;
case 'Flows': {
// Flows reference ApplicationComponents via Source and Target attributes
// We need to find Flows where this ApplicationComponent is the target of the reference
relatedObjects = await cmdbService.getReferencingObjects<Flows>(id, 'Flows');
logger.debug(`Found ${relatedObjects.length} Flows referencing application ${id} in cache`);
break;
}
case 'Certificate':
relatedObjects = await cmdbService.getReferencingObjects<Certificate>(id, 'Certificate');
logger.debug(`Found ${relatedObjects.length} Certificates referencing application ${id} in cache`);
break;
case 'Domain':
relatedObjects = await cmdbService.getReferencingObjects<Domain>(id, 'Domain');
logger.debug(`Found ${relatedObjects.length} Domains referencing application ${id} in cache`);
break;
case 'AzureSubscription':
relatedObjects = await cmdbService.getReferencingObjects<AzureSubscription>(id, 'AzureSubscription');
logger.debug(`Found ${relatedObjects.length} AzureSubscriptions referencing application ${id} in cache`);
break;
default:
relatedObjects = [];
logger.warn(`Unknown object type for related objects: ${typeName}`);
}
// If no objects found in cache, try to fetch from Jira directly as fallback
// This helps when relations haven't been synced yet
if (relatedObjects.length === 0) {
try {
// Get application to get its objectKey
const app = await cmdbService.getObject('ApplicationComponent', id);
if (!app) {
logger.warn(`Application ${id} not found in cache, cannot fetch related objects from Jira`);
} else if (!app.objectKey) {
logger.warn(`Application ${id} has no objectKey, cannot fetch related objects from Jira`);
} else {
logger.info(`No related ${typeName} objects found in cache for application ${id} (${app.objectKey}), trying Jira directly...`);
const { jiraAssetsService } = await import('../services/jiraAssets.js');
// Use the Jira object type name from schema (not our internal typeName)
const { OBJECT_TYPES } = await import('../generated/jira-schema.js');
const jiraTypeDef = OBJECT_TYPES[typeName];
const jiraObjectTypeName = jiraTypeDef?.name || objectType;
logger.debug(`Using Jira object type name: "${jiraObjectTypeName}" for internal type "${typeName}"`);
const jiraResult = await jiraAssetsService.getRelatedObjects(app.objectKey, jiraObjectTypeName, requestedAttrs);
logger.debug(`Jira query returned ${jiraResult?.objects?.length || 0} objects`);
if (jiraResult && jiraResult.objects && jiraResult.objects.length > 0) {
logger.info(`Found ${jiraResult.objects.length} related ${typeName} objects from Jira, caching them...`);
// Batch fetch and cache all objects at once (much more efficient)
const objectIds = jiraResult.objects.map(obj => obj.id.toString());
const cachedObjects = await cmdbService.batchFetchAndCacheObjects(typeName as CMDBObjectTypeName, objectIds);
logger.info(`Successfully batch cached ${cachedObjects.length} of ${jiraResult.objects.length} related ${typeName} objects`);
// Use cached objects, fallback to minimal objects from Jira result if not found
const cachedById = new Map(cachedObjects.map(obj => [obj.id, obj]));
relatedObjects = jiraResult.objects.map((jiraObj) => {
const cached = cachedById.get(jiraObj.id.toString());
if (cached) {
return cached as RelatedObjectType;
}
// Fallback: create minimal object from Jira result
logger.debug(`Creating minimal object for ${jiraObj.id} (${jiraObj.key}) as cache lookup failed`);
return {
id: jiraObj.id.toString(),
objectKey: jiraObj.key,
label: jiraObj.label,
_objectType: typeName,
} as RelatedObjectType;
});
logger.info(`Loaded ${relatedObjects.length} related ${typeName} objects (${relatedObjects.filter(o => o).length} valid)`);
} else {
logger.info(`No related ${typeName} objects found in Jira for application ${app.objectKey}`);
}
}
} catch (error) {
logger.error(`Failed to fetch related ${typeName} objects from Jira as fallback for application ${id}:`, error);
}
}
// Format response - must match RelatedObjectsResponse type expected by frontend
const objects = relatedObjects.map(obj => {
// Extract attributes from the object
const attributes: Record<string, string | null> = {};
const objData = obj as unknown as Record<string, unknown>;
// If specific attributes are requested, extract those
if (requestedAttrs.length > 0) {
for (const attrName of requestedAttrs) {
// Convert attribute name to camelCase field name
const fieldName = attrName.charAt(0).toLowerCase() + attrName.slice(1).replace(/\s+/g, '');
const value = objData[fieldName] ?? objData[attrName.toLowerCase()] ?? objData[attrName];
if (value === null || value === undefined) {
attributes[attrName] = null;
} else if (typeof value === 'object' && value !== null) {
// ObjectReference - extract label
const ref = value as { label?: string; name?: string; displayValue?: string };
attributes[attrName] = ref.label || ref.name || ref.displayValue || null;
} else {
attributes[attrName] = String(value);
}
}
} else {
// No specific attributes requested - include common ones
if ('status' in objData) {
const status = objData.status;
if (typeof status === 'object' && status !== null) {
attributes['Status'] = (status as { label?: string }).label || String(status);
} else if (status) {
attributes['Status'] = String(status);
}
}
if ('state' in objData) {
attributes['State'] = objData.state ? String(objData.state) : null;
}
}
return {
id: obj.id,
key: obj.objectKey,
label: obj.label,
name: obj.label,
objectType: obj._objectType,
attributes,
};
});
res.json({ objects, total: objects.length });
} catch (error) {
logger.error(`Failed to get related ${req.params.objectType} objects`, error);
res.status(500).json({ error: `Failed to get related objects` });
}
});
export default router;