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 } from '../utils/queryHelpers.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(); // Search applications with filters router.post('/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 = 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 = 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 = 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 = 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(); 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 = 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) => { try { const { id } = req.params; 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' }); 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', error); res.status(500).json({ error: 'Failed to get application' }); } }); // Update application with conflict detection router.put('/:id', async (req: Request, res: Response) => { try { const { id } = req.params; 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) router.put('/:id/force', async (req: Request, res: Response) => { try { const { id } = req.params; 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; // 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 } = req.params; 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, objectType } = req.params; // Map object type string to CMDBObjectTypeName const typeMap: Record = { '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[] = []; switch (typeName) { case 'Server': relatedObjects = await cmdbService.getReferencingObjects(id, 'Server'); 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(id, 'Flows'); break; } case 'Certificate': relatedObjects = await cmdbService.getReferencingObjects(id, 'Certificate'); break; case 'Domain': relatedObjects = await cmdbService.getReferencingObjects(id, 'Domain'); break; case 'AzureSubscription': relatedObjects = await cmdbService.getReferencingObjects(id, 'AzureSubscription'); break; default: relatedObjects = []; } // Get requested attributes from query string const attributesParam = getQueryString(req, 'attributes'); const requestedAttrs = attributesParam ? attributesParam.split(',').map(a => a.trim()) : []; // Format response - must match RelatedObjectsResponse type expected by frontend const objects = relatedObjects.map(obj => { // Extract attributes from the object const attributes: Record = {}; const objData = obj as unknown as Record; // 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;