From ca21b9538d603254dece6f6b039783b0c63c3988 Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Sat, 10 Jan 2026 02:16:55 +0100 Subject: [PATCH] Improve Team-indeling dashboard UI and cache invalidation - Replace 'TEAM' label with Type attribute (Business/Enabling/Staf) in team blocks - Make Type labels larger (text-sm) and brighter colors - Make SUBTEAM label less bright (indigo-300) and smaller (text-[10px]) - Add 'FTE' suffix to bandbreedte values in header and application blocks - Add Platform and Connected Device labels to application blocks - Show Platform FTE and Workloads FTE separately in Platform blocks - Add spacing between Regiemodel letter and count value - Add cache invalidation for Team Dashboard when applications are updated - Enrich team references with Type attribute in getSubteamToTeamMapping --- .env.example | 30 - CLAUDE.md | 77 +- backend/package.json | 3 +- backend/scripts/generate-schema.ts | 982 ++++++++++++ backend/src/config/env.ts | 167 ++- backend/src/generated/db-schema.sql | 54 + backend/src/generated/jira-schema.ts | 894 +++++++++++ backend/src/generated/jira-types.ts | 933 ++++++++++++ backend/src/index.ts | 57 +- backend/src/routes/applications.ts | 265 +++- backend/src/routes/auth.ts | 5 + backend/src/routes/cache.ts | 135 ++ backend/src/routes/configuration.ts | 7 +- backend/src/routes/dashboard.ts | 131 +- backend/src/routes/objects.ts | 176 +++ backend/src/routes/referenceData.ts | 35 +- backend/src/routes/schema.ts | 151 ++ backend/src/routes/search.ts | 74 + backend/src/services/authService.ts | 15 +- backend/src/services/cacheStore.ts | 660 +++++++++ backend/src/services/claude.ts | 36 +- backend/src/services/cmdbService.ts | 445 ++++++ backend/src/services/conflictResolver.ts | 254 ++++ backend/src/services/dataService.ts | 1084 ++++++++++++-- backend/src/services/effortCalculation.ts | 7 +- backend/src/services/jiraAssets.ts | 1065 +++++++++---- backend/src/services/jiraAssetsClient.ts | 425 ++++++ backend/src/services/mockData.ts | 140 +- backend/src/services/syncEngine.ts | 463 ++++++ backend/src/types/index.ts | 58 +- frontend/index.html | 4 +- frontend/public/logo-zuyderland.svg | 1 + frontend/src/App.tsx | 215 ++- frontend/src/components/ApplicationInfo.tsx | 620 ++++++++ frontend/src/components/ApplicationList.tsx | 59 +- .../src/components/CacheStatusIndicator.tsx | 180 +++ frontend/src/components/ConflictDialog.tsx | 162 ++ frontend/src/components/CustomSelect.tsx | 15 +- frontend/src/components/Dashboard.tsx | 207 ++- .../src/components/DataModelDashboard.tsx | 648 ++++++++ frontend/src/components/EffortDisplay.tsx | 258 ++++ frontend/src/components/FTECalculator.tsx | 337 +++++ .../src/components/GovernanceAnalysis.tsx | 303 ++++ ...onDetail.tsx => GovernanceModelHelper.tsx} | 771 +++++----- frontend/src/components/Login.tsx | 12 +- frontend/src/components/ReportsDashboard.tsx | 165 +++ frontend/src/components/SearchDashboard.tsx | 548 +++++++ frontend/src/components/TeamDashboard.tsx | 1319 ++++++++++------- frontend/src/hooks/useEffortCalculation.ts | 212 +++ frontend/src/services/api.ts | 306 +++- frontend/src/stores/authStore.ts | 3 + frontend/src/stores/searchStore.ts | 8 +- frontend/src/types/index.ts | 49 +- package.json | 3 +- 54 files changed, 13444 insertions(+), 1789 deletions(-) create mode 100644 backend/scripts/generate-schema.ts create mode 100644 backend/src/generated/db-schema.sql create mode 100644 backend/src/generated/jira-schema.ts create mode 100644 backend/src/generated/jira-types.ts create mode 100644 backend/src/routes/cache.ts create mode 100644 backend/src/routes/objects.ts create mode 100644 backend/src/routes/schema.ts create mode 100644 backend/src/routes/search.ts create mode 100644 backend/src/services/cacheStore.ts create mode 100644 backend/src/services/cmdbService.ts create mode 100644 backend/src/services/conflictResolver.ts create mode 100644 backend/src/services/jiraAssetsClient.ts create mode 100644 backend/src/services/syncEngine.ts create mode 100644 frontend/public/logo-zuyderland.svg create mode 100644 frontend/src/components/ApplicationInfo.tsx create mode 100644 frontend/src/components/CacheStatusIndicator.tsx create mode 100644 frontend/src/components/ConflictDialog.tsx create mode 100644 frontend/src/components/DataModelDashboard.tsx create mode 100644 frontend/src/components/EffortDisplay.tsx create mode 100644 frontend/src/components/FTECalculator.tsx create mode 100644 frontend/src/components/GovernanceAnalysis.tsx rename frontend/src/components/{ApplicationDetail.tsx => GovernanceModelHelper.tsx} (82%) create mode 100644 frontend/src/components/ReportsDashboard.tsx create mode 100644 frontend/src/components/SearchDashboard.tsx create mode 100644 frontend/src/hooks/useEffortCalculation.ts diff --git a/.env.example b/.env.example index 967e9c1..f4cc9c1 100644 --- a/.env.example +++ b/.env.example @@ -5,36 +5,6 @@ JIRA_SCHEMA_ID=your_schema_id JIRA_API_BATCH_SIZE=20 -# Object Type IDs (retrieve via API) -JIRA_APPLICATION_COMPONENT_TYPE_ID=your_type_id -JIRA_APPLICATION_FUNCTION_TYPE_ID=your_function_type_id -JIRA_DYNAMICS_FACTOR_TYPE_ID=your_dynamics_factor_type_id -JIRA_COMPLEXITY_FACTOR_TYPE_ID=your_complexity_factor_type_id -JIRA_NUMBER_OF_USERS_TYPE_ID=your_number_of_users_type_id -JIRA_GOVERNANCE_MODEL_TYPE_ID=your_governance_model_type_id -JIRA_APPLICATION_CLUSTER_TYPE_ID=your_application_cluster_type_id -JIRA_APPLICATION_TYPE_TYPE_ID=your_application_type_type_id -JIRA_BUSINESS_IMPACT_ANALYSE_TYPE_ID=your_business_impact_analyse_type_id -JIRA_HOSTING_TYPE_TYPE_ID=your_hosting_type_type_id -JIRA_HOSTING_TYPE_ID=your_hosting_type_id -JIRA_TAM_TYPE_ID=your_tam_type_id - -# Attribute IDs (retrieve via API - needed for updates) -JIRA_ATTR_APPLICATION_FUNCTION=attribute_id -JIRA_ATTR_DYNAMICS_FACTOR=attribute_id -JIRA_ATTR_COMPLEXITY_FACTOR=attribute_id -JIRA_ATTR_NUMBER_OF_USERS=attribute_id -JIRA_ATTR_GOVERNANCE_MODEL=attribute_id -JIRA_ATTR_APPLICATION_CLUSTER=attribute_id -JIRA_ATTR_APPLICATION_TYPE=attribute_id -JIRA_ATTR_PLATFORM=attribute_id -JIRA_ATTR_BUSINESS_IMPACT_ANALYSE=attribute_id -JIRA_ATTR_HOSTING_TYPE=attribute_id -JIRA_ATTR_TECHNISCHE_ARCHITECTUUR=attribute_id -JIRA_ATTR_HOSTING=attribute_id -JIRA_ATTR_TAM=attribute_id - - # Claude API ANTHROPIC_API_KEY=your_anthropic_api_key_here diff --git a/CLAUDE.md b/CLAUDE.md index a6cc70f..a2ad8a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,19 +93,41 @@ zira-classificatie-tool/ │ ├── App.tsx # Main component with routing │ ├── index.css # Tailwind CSS imports │ ├── components/ -│ │ ├── Dashboard.tsx # Overview statistics -│ │ ├── ApplicationList.tsx # Search & filter view -│ │ └── ApplicationDetail.tsx # Edit & AI classify +│ │ ├── SearchDashboard.tsx # Main dashboard with CMDB search +│ │ ├── Dashboard.tsx # App Component statistics +│ │ ├── ApplicationList.tsx # Application overview & filter +│ │ ├── ApplicationDetail.tsx # Edit & AI classify +│ │ ├── TeamDashboard.tsx # Team FTE dashboard +│ │ ├── ConfigurationV25.tsx # FTE configuration +│ │ └── ReportsDashboard.tsx # Reports overview │ ├── services/api.ts # API client │ ├── stores/ │ │ ├── searchStore.ts # Filter state (Zustand) -│ │ └── navigationStore.ts # Navigation state +│ │ ├── navigationStore.ts # Navigation state +│ │ └── authStore.ts # Authentication state │ └── types/index.ts # TypeScript interfaces └── data/ ├── zira-taxonomy.json └── management-parameters.json ``` +## Navigation Structure + +The application uses a hierarchical menu structure: + +``` +Dashboard (/) # CMDB search page +│ +├── Application Component (/app-components) +│ ├── Dashboard (/app-components) # Statistics & overview +│ ├── Overzicht (/app-components/overview) # Application list & filter +│ └── FTE Config (/app-components/fte-config) # FTE calculation config +│ +└── Rapporten (/reports) + ├── Overzicht (/reports) # Reports dashboard + └── Team-indeling (/reports/team-dashboard) # Team FTE dashboard +``` + ## Key Domain Concepts ### ZiRA (Ziekenhuis Referentie Architectuur) @@ -134,17 +156,21 @@ Dutch hospital reference architecture with 90+ application functions organized i ```env # Jira Data Center JIRA_HOST=https://jira.zuyderland.nl -JIRA_PAT= # Service account PAT (fallback when OAuth disabled) JIRA_SCHEMA_ID= -# Jira OAuth 2.0 (optional - enables user authentication) -JIRA_OAUTH_ENABLED=false # Set to 'true' to enable OAuth +# Jira Authentication Method: 'pat' or 'oauth' +JIRA_AUTH_METHOD=pat # Choose: 'pat' (Personal Access Token) or 'oauth' (OAuth 2.0) + +# For PAT authentication (JIRA_AUTH_METHOD=pat) +JIRA_PAT= # Personal Access Token for Jira API access + +# For OAuth 2.0 authentication (JIRA_AUTH_METHOD=oauth) JIRA_OAUTH_CLIENT_ID= # From Jira Application Link JIRA_OAUTH_CLIENT_SECRET= # From Jira Application Link JIRA_OAUTH_CALLBACK_URL=http://localhost:3001/api/auth/callback JIRA_OAUTH_SCOPES=READ WRITE -# Session Configuration +# Session Configuration (required for OAuth) SESSION_SECRET= # Change in production! # Jira Object Type IDs @@ -179,17 +205,34 @@ FRONTEND_URL=http://localhost:5173 ## Authentication -The application supports two authentication modes: +The application supports two authentication methods, configured via `JIRA_AUTH_METHOD`: -### 1. Service Account Mode (Default) -- Uses a single PAT (`JIRA_PAT`) for all Jira API calls +### 1. Personal Access Token (PAT) Mode (`JIRA_AUTH_METHOD=pat`) +- **Default mode** - Uses a single PAT for all Jira API calls - Users don't need to log in - All changes are attributed to the service account +- Best for: Development, internal tools, or when user attribution isn't required -### 2. OAuth 2.0 Mode +**Configuration:** +```env +JIRA_AUTH_METHOD=pat +JIRA_PAT=your_personal_access_token +``` + +### 2. OAuth 2.0 Mode (`JIRA_AUTH_METHOD=oauth`) - Each user logs in with their own Jira credentials - API calls are made under the user's account - Better audit trail and access control +- Best for: Production environments where user attribution matters + +**Configuration:** +```env +JIRA_AUTH_METHOD=oauth +JIRA_OAUTH_CLIENT_ID=your_client_id +JIRA_OAUTH_CLIENT_SECRET=your_client_secret +JIRA_OAUTH_CALLBACK_URL=http://localhost:3001/api/auth/callback +SESSION_SECRET=your_secure_random_string +``` ### Setting up OAuth 2.0 (Jira Data Center 8.14+) @@ -199,17 +242,11 @@ The application supports two authentication modes: - Set Redirect URL: `http://localhost:3001/api/auth/callback` - Note the Client ID and Secret -2. **Configure Environment:** - ```env - JIRA_OAUTH_ENABLED=true - JIRA_OAUTH_CLIENT_ID=your_client_id - JIRA_OAUTH_CLIENT_SECRET=your_client_secret - JIRA_OAUTH_CALLBACK_URL=http://localhost:3001/api/auth/callback - ``` +2. **Configure Environment Variables** (see above) 3. **For Production:** - Update callback URL to production domain - - Set `SESSION_SECRET` to a random string + - Set `SESSION_SECRET` to a cryptographically secure random string - Use HTTPS ## Implementation Notes diff --git a/backend/package.json b/backend/package.json index e1d6e6f..911b99e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "generate-schema": "tsx scripts/generate-schema.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.32.1", diff --git a/backend/scripts/generate-schema.ts b/backend/scripts/generate-schema.ts new file mode 100644 index 0000000..250bc27 --- /dev/null +++ b/backend/scripts/generate-schema.ts @@ -0,0 +1,982 @@ +#!/usr/bin/env npx tsx + +/** + * Schema Generator - Fetches Jira Assets schema DYNAMICALLY and generates: + * - TypeScript types (jira-types.ts) + * - Schema metadata (jira-schema.ts) + * - Database schema (db-schema.sql) + * + * This script connects to the Jira Assets API to discover ALL object types + * and their attributes, ensuring the data model is always in sync with the + * actual CMDB configuration. + * + * Usage: npm run generate-schema + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; + +// Load environment variables +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +// Try multiple possible .env locations +const envPaths = [ + path.resolve(__dirname, '../../.env'), // backend/.env + path.resolve(__dirname, '../../../.env'), // project root .env +]; +let envLoaded = ''; +for (const envPath of envPaths) { + if (fs.existsSync(envPath)) { + dotenv.config({ path: envPath }); + envLoaded = envPath; + break; + } +} + +// Configuration +const JIRA_HOST = process.env.JIRA_HOST || ''; +const JIRA_PAT = process.env.JIRA_PAT || ''; +const JIRA_SCHEMA_ID = process.env.JIRA_SCHEMA_ID || ''; + +const OUTPUT_DIR = path.resolve(__dirname, '../src/generated'); + +// ============================================================================= +// Interfaces +// ============================================================================= + +interface JiraObjectSchema { + id: number; + name: string; + objectSchemaKey: string; + description?: string; + objectCount?: number; +} + +interface JiraObjectType { + id: number; + name: string; + description?: string; + iconId?: number; + objectCount?: number; + parentObjectTypeId?: number; + objectSchemaId: number; + inherited?: boolean; + abstractObjectType?: boolean; + parentObjectTypeInherited?: boolean; +} + +interface JiraAttribute { + id: number; + name: string; + label?: string; + type: number; + typeValue?: string; + defaultType?: { id: number; name: string }; + referenceObjectTypeId?: number; + referenceObjectType?: { id: number; name: string }; + referenceType?: { id: number; name: string }; + minimumCardinality?: number; + maximumCardinality?: number; + editable?: boolean; + hidden?: boolean; + system?: boolean; + options?: string; + description?: string; +} + +interface GeneratedAttribute { + jiraId: number; + name: string; + fieldName: string; + type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown'; + isMultiple: boolean; + isEditable: boolean; + isRequired: boolean; + isSystem: boolean; + referenceTypeId?: number; + referenceTypeName?: string; + description?: string; +} + +interface GeneratedObjectType { + jiraTypeId: number; + name: string; + typeName: string; + syncPriority: number; + objectCount: number; + attributes: GeneratedAttribute[]; +} + +// ============================================================================= +// Jira Type Mapping +// ============================================================================= + +// Jira attribute type mappings (based on Jira Insight/Assets API) +// See: https://docs.atlassian.com/jira-servicemanagement-docs/REST/5.x/insight/1.0/objecttypeattribute +const JIRA_TYPE_MAP: Record = { + 0: 'text', // Default/Text + 1: 'integer', // Integer + 2: 'boolean', // Boolean + 3: 'float', // Double/Float + 4: 'date', // Date + 5: 'datetime', // DateTime + 6: 'url', // URL + 7: 'email', // Email + 8: 'textarea', // Textarea + 9: 'select', // Select + 10: 'reference', // Reference (Object) + 11: 'user', // User + 12: 'reference', // Confluence (treated as reference) + 13: 'reference', // Group (treated as reference) + 14: 'reference', // Version (treated as reference) + 15: 'reference', // Project (treated as reference) + 16: 'status', // Status +}; + +// Priority types - these sync first as they are reference data +const PRIORITY_TYPE_NAMES = new Set([ + 'Application Component', + 'Server', + 'Flows', +]); + +// Reference data types - these sync with lower priority +const REFERENCE_TYPE_PATTERNS = [ + /Factor$/, + /Model$/, + /Type$/, + /Category$/, + /Importance$/, + /Analyse$/, + /Organisation$/, + /Function$/, +]; + +// ============================================================================= +// Jira Schema Fetcher +// ============================================================================= + +class JiraSchemaFetcher { + private baseUrl: string; + private headers: Record; + + constructor(host: string, pat: string) { + this.baseUrl = `${host}/rest/insight/1.0`; + this.headers = { + 'Authorization': `Bearer ${pat}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + } + + /** + * Fetch schema info + */ + async fetchSchema(schemaId: string): Promise { + try { + const response = await fetch(`${this.baseUrl}/objectschema/${schemaId}`, { + headers: this.headers, + }); + + if (!response.ok) { + console.error(`Failed to fetch schema ${schemaId}: ${response.status} ${response.statusText}`); + const text = await response.text(); + console.error(`Response: ${text}`); + return null; + } + + return await response.json(); + } catch (error) { + console.error(`Error fetching schema ${schemaId}:`, error); + return null; + } + } + + /** + * Fetch ALL object types in the schema + */ + async fetchAllObjectTypes(schemaId: string): Promise { + try { + // Try the objecttypes/flat endpoint first (returns all types in flat structure) + let response = await fetch(`${this.baseUrl}/objectschema/${schemaId}/objecttypes/flat`, { + headers: this.headers, + }); + + if (!response.ok) { + // Fallback to regular objecttypes endpoint + response = await fetch(`${this.baseUrl}/objectschema/${schemaId}/objecttypes`, { + headers: this.headers, + }); + } + + if (!response.ok) { + console.error(`Failed to fetch object types: ${response.status} ${response.statusText}`); + const text = await response.text(); + console.error(`Response: ${text}`); + return []; + } + + const result = await response.json(); + + // Handle both array and object responses + if (Array.isArray(result)) { + return result; + } else if (result.objectTypes) { + return result.objectTypes; + } + + return []; + } catch (error) { + console.error(`Error fetching object types:`, error); + return []; + } + } + + /** + * Fetch attributes for a specific object type + */ + async fetchAttributes(typeId: number): Promise { + try { + const response = await fetch(`${this.baseUrl}/objecttype/${typeId}/attributes`, { + headers: this.headers, + }); + + if (!response.ok) { + console.error(`Failed to fetch attributes for type ${typeId}: ${response.status}`); + return []; + } + + return await response.json(); + } catch (error) { + console.error(`Error fetching attributes for type ${typeId}:`, error); + return []; + } + } + + /** + * Test the connection + */ + async testConnection(): Promise { + try { + const response = await fetch(`${this.baseUrl}/objectschema/list`, { + headers: this.headers, + }); + return response.ok; + } catch { + return false; + } + } +} + +// ============================================================================= +// Utility Functions +// ============================================================================= + +/** + * Convert a string to camelCase while preserving existing casing patterns + * E.g., "Application Function" -> "applicationFunction" + * "ICT Governance Model" -> "ictGovernanceModel" + * "ApplicationFunction" -> "applicationFunction" + */ +function toCamelCase(str: string): string { + // First split on spaces and special chars + const words = str + .replace(/[^a-zA-Z0-9\s]/g, ' ') + .split(/\s+/) + .filter(w => w.length > 0); + + if (words.length === 0) return ''; + + // If it's a single word that's already camelCase or PascalCase, just lowercase first char + if (words.length === 1) { + const word = words[0]; + return word.charAt(0).toLowerCase() + word.slice(1); + } + + // Multiple words - first word lowercase, rest capitalize first letter + return words + .map((word, index) => { + if (index === 0) { + // First word: if all uppercase (acronym), lowercase it, otherwise just lowercase first char + if (word === word.toUpperCase() && word.length > 1) { + return word.toLowerCase(); + } + return word.charAt(0).toLowerCase() + word.slice(1); + } + // Other words: capitalize first letter, keep rest as-is + return word.charAt(0).toUpperCase() + word.slice(1); + }) + .join(''); +} + +/** + * Convert a string to PascalCase while preserving existing casing patterns + * E.g., "Application Function" -> "ApplicationFunction" + * "ICT Governance Model" -> "IctGovernanceModel" + * "applicationFunction" -> "ApplicationFunction" + */ +function toPascalCase(str: string): string { + // First split on spaces and special chars + const words = str + .replace(/[^a-zA-Z0-9\s]/g, ' ') + .split(/\s+/) + .filter(w => w.length > 0); + + if (words.length === 0) return ''; + + // If it's a single word, just capitalize first letter + if (words.length === 1) { + const word = words[0]; + return word.charAt(0).toUpperCase() + word.slice(1); + } + + // Multiple words - capitalize first letter of each + return words + .map(word => { + // If all uppercase (acronym) and first word, just capitalize first letter + if (word === word.toUpperCase() && word.length > 1) { + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + } + return word.charAt(0).toUpperCase() + word.slice(1); + }) + .join(''); +} + +function mapJiraType(typeId: number): GeneratedAttribute['type'] { + return JIRA_TYPE_MAP[typeId] || 'unknown'; +} + +function determineSyncPriority(typeName: string, objectCount: number): number { + // Application Component and related main types first + if (PRIORITY_TYPE_NAMES.has(typeName)) { + return 1; + } + + // Reference data types last + for (const pattern of REFERENCE_TYPE_PATTERNS) { + if (pattern.test(typeName)) { + return 10; + } + } + + // Medium priority for types with more objects + if (objectCount > 100) { + return 2; + } + if (objectCount > 10) { + return 5; + } + + return 8; +} + +function parseAttribute( + attr: JiraAttribute, + allTypeConfigs: Map +): GeneratedAttribute { + const typeId = attr.type || attr.defaultType?.id || 0; + let type = mapJiraType(typeId); + const isMultiple = (attr.maximumCardinality ?? 1) > 1 || attr.maximumCardinality === -1; + const isEditable = attr.editable !== false && !attr.hidden; + const isRequired = (attr.minimumCardinality ?? 0) > 0; + const isSystem = attr.system === true; + + // CRITICAL: Jira sometimes returns type=1 (integer) for reference attributes! + // The presence of referenceObjectTypeId is the true indicator of a reference type. + const refTypeId = attr.referenceObjectTypeId || attr.referenceObjectType?.id || attr.referenceType?.id; + if (refTypeId) { + type = 'reference'; + } + + const result: GeneratedAttribute = { + jiraId: attr.id, + name: attr.name, + fieldName: toCamelCase(attr.name), + type, + isMultiple, + isEditable, + isRequired, + isSystem, + description: attr.description, + }; + + // Handle reference types - add reference metadata + if (type === 'reference' && refTypeId) { + result.referenceTypeId = refTypeId; + const refConfig = allTypeConfigs.get(refTypeId); + result.referenceTypeName = refConfig?.typeName || + attr.referenceObjectType?.name || + attr.referenceType?.name || + `Type${refTypeId}`; + } + + return result; +} + +// ============================================================================= +// Code Generation Functions +// ============================================================================= + +function generateTypeScriptType(attrType: GeneratedAttribute['type'], isMultiple: boolean, isReference: boolean): string { + let tsType: string; + + if (isReference) { + tsType = 'ObjectReference'; + } else { + switch (attrType) { + case 'text': + case 'textarea': + case 'url': + case 'email': + case 'select': + case 'user': + case 'status': + tsType = 'string'; + break; + case 'integer': + case 'float': + tsType = 'number'; + break; + case 'boolean': + tsType = 'boolean'; + break; + case 'date': + case 'datetime': + tsType = 'string'; // ISO date string + break; + default: + tsType = 'unknown'; + } + } + + if (isMultiple) { + return `${tsType}[]`; + } + return `${tsType} | null`; +} + +function generateTypesFile(objectTypes: GeneratedObjectType[], generatedAt: Date): string { + const lines: string[] = [ + '// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY', + '// Generated from Jira Assets Schema via REST API', + `// Generated at: ${generatedAt.toISOString()}`, + '//', + '// Re-generate with: npm run generate-schema', + '', + '// =============================================================================', + '// Base Types', + '// =============================================================================', + '', + '/** Reference to another CMDB object */', + 'export interface ObjectReference {', + ' objectId: string;', + ' objectKey: string;', + ' label: string;', + ' // Optional enriched data from referenced object', + ' factor?: number;', + '}', + '', + '/** Base interface for all CMDB objects */', + 'export interface BaseCMDBObject {', + ' id: string;', + ' objectKey: string;', + ' label: string;', + ' _objectType: string;', + ' _jiraUpdatedAt: string;', + ' _jiraCreatedAt: string;', + '}', + '', + '// =============================================================================', + '// Object Type Interfaces', + '// =============================================================================', + '', + ]; + + for (const objType of objectTypes) { + lines.push(`/** ${objType.name} (Jira Type ID: ${objType.jiraTypeId}, ${objType.objectCount} objects) */`); + lines.push(`export interface ${objType.typeName} extends BaseCMDBObject {`); + lines.push(` _objectType: '${objType.typeName}';`); + lines.push(''); + + // Group attributes by type + const scalarAttrs = objType.attributes.filter(a => a.type !== 'reference'); + const refAttrs = objType.attributes.filter(a => a.type === 'reference'); + + if (scalarAttrs.length > 0) { + lines.push(' // Scalar attributes'); + for (const attr of scalarAttrs) { + const tsType = generateTypeScriptType(attr.type, attr.isMultiple, false); + const comment = attr.description ? ` // ${attr.description}` : ''; + lines.push(` ${attr.fieldName}: ${tsType};${comment}`); + } + lines.push(''); + } + + if (refAttrs.length > 0) { + lines.push(' // Reference attributes'); + for (const attr of refAttrs) { + const tsType = generateTypeScriptType(attr.type, attr.isMultiple, true); + const comment = attr.referenceTypeName ? ` // -> ${attr.referenceTypeName}` : ''; + lines.push(` ${attr.fieldName}: ${tsType};${comment}`); + } + lines.push(''); + } + + lines.push('}'); + lines.push(''); + } + + // Generate union type + lines.push('// ============================================================================='); + lines.push('// Union Types'); + lines.push('// ============================================================================='); + lines.push(''); + lines.push('/** Union of all CMDB object types */'); + lines.push('export type CMDBObject ='); + for (let i = 0; i < objectTypes.length; i++) { + const suffix = i < objectTypes.length - 1 ? '' : ';'; + lines.push(` | ${objectTypes[i].typeName}${suffix}`); + } + lines.push(''); + + // Generate type name literal union + lines.push('/** All valid object type names */'); + lines.push('export type CMDBObjectTypeName ='); + for (let i = 0; i < objectTypes.length; i++) { + const suffix = i < objectTypes.length - 1 ? '' : ';'; + lines.push(` | '${objectTypes[i].typeName}'${suffix}`); + } + lines.push(''); + + // Generate type guards + lines.push('// ============================================================================='); + lines.push('// Type Guards'); + lines.push('// ============================================================================='); + lines.push(''); + for (const objType of objectTypes) { + lines.push(`export function is${objType.typeName}(obj: CMDBObject): obj is ${objType.typeName} {`); + lines.push(` return obj._objectType === '${objType.typeName}';`); + lines.push('}'); + lines.push(''); + } + + return lines.join('\n'); +} + +function generateSchemaFile(objectTypes: GeneratedObjectType[], generatedAt: Date): string { + const lines: string[] = [ + '// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY', + '// Generated from Jira Assets Schema via REST API', + `// Generated at: ${generatedAt.toISOString()}`, + '//', + '// Re-generate with: npm run generate-schema', + '', + '// =============================================================================', + '// Schema Type Definitions', + '// =============================================================================', + '', + 'export interface AttributeDefinition {', + ' jiraId: number;', + ' name: string;', + ' fieldName: string;', + " type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown';", + ' isMultiple: boolean;', + ' isEditable: boolean;', + ' isRequired: boolean;', + ' isSystem: boolean;', + ' referenceTypeId?: number;', + ' referenceTypeName?: string;', + ' description?: string;', + '}', + '', + 'export interface ObjectTypeDefinition {', + ' jiraTypeId: number;', + ' name: string;', + ' typeName: string;', + ' syncPriority: number;', + ' objectCount: number;', + ' attributes: AttributeDefinition[];', + '}', + '', + '// =============================================================================', + '// Schema Metadata', + '// =============================================================================', + '', + `export const SCHEMA_GENERATED_AT = '${generatedAt.toISOString()}';`, + `export const SCHEMA_OBJECT_TYPE_COUNT = ${objectTypes.length};`, + `export const SCHEMA_TOTAL_ATTRIBUTES = ${objectTypes.reduce((sum, ot) => sum + ot.attributes.length, 0)};`, + '', + '// =============================================================================', + '// Object Type Definitions', + '// =============================================================================', + '', + 'export const OBJECT_TYPES: Record = {', + ]; + + for (let i = 0; i < objectTypes.length; i++) { + const objType = objectTypes[i]; + const comma = i < objectTypes.length - 1 ? ',' : ''; + + lines.push(` '${objType.typeName}': {`); + lines.push(` jiraTypeId: ${objType.jiraTypeId},`); + lines.push(` name: '${objType.name.replace(/'/g, "\\'")}',`); + lines.push(` typeName: '${objType.typeName}',`); + lines.push(` syncPriority: ${objType.syncPriority},`); + lines.push(` objectCount: ${objType.objectCount},`); + lines.push(' attributes: ['); + + for (let j = 0; j < objType.attributes.length; j++) { + const attr = objType.attributes[j]; + const attrComma = j < objType.attributes.length - 1 ? ',' : ''; + + let attrLine = ` { jiraId: ${attr.jiraId}, name: '${attr.name.replace(/'/g, "\\'")}', fieldName: '${attr.fieldName}', type: '${attr.type}', isMultiple: ${attr.isMultiple}, isEditable: ${attr.isEditable}, isRequired: ${attr.isRequired}, isSystem: ${attr.isSystem}`; + + if (attr.referenceTypeId) { + attrLine += `, referenceTypeId: ${attr.referenceTypeId}, referenceTypeName: '${attr.referenceTypeName}'`; + } + if (attr.description) { + attrLine += `, description: '${attr.description.replace(/'/g, "\\'").replace(/\n/g, ' ')}'`; + } + + attrLine += ` }${attrComma}`; + lines.push(attrLine); + } + + lines.push(' ],'); + lines.push(` }${comma}`); + } + + lines.push('};'); + lines.push(''); + + // Generate lookup maps + lines.push('// ============================================================================='); + lines.push('// Lookup Maps'); + lines.push('// ============================================================================='); + lines.push(''); + + // Type ID to name map + lines.push('/** Map from Jira Type ID to TypeScript type name */'); + lines.push('export const TYPE_ID_TO_NAME: Record = {'); + for (const objType of objectTypes) { + lines.push(` ${objType.jiraTypeId}: '${objType.typeName}',`); + } + lines.push('};'); + lines.push(''); + + // Type name to ID map + lines.push('/** Map from TypeScript type name to Jira Type ID */'); + lines.push('export const TYPE_NAME_TO_ID: Record = {'); + for (const objType of objectTypes) { + lines.push(` '${objType.typeName}': ${objType.jiraTypeId},`); + } + lines.push('};'); + lines.push(''); + + // Jira name to TypeScript name map + lines.push('/** Map from Jira object type name to TypeScript type name */'); + lines.push('export const JIRA_NAME_TO_TYPE: Record = {'); + for (const objType of objectTypes) { + lines.push(` '${objType.name.replace(/'/g, "\\'")}': '${objType.typeName}',`); + } + lines.push('};'); + lines.push(''); + + // Helper functions + lines.push('// ============================================================================='); + lines.push('// Helper Functions'); + lines.push('// ============================================================================='); + lines.push(''); + + lines.push('/** Get attribute definition by type and field name */'); + lines.push('export function getAttributeDefinition(typeName: string, fieldName: string): AttributeDefinition | undefined {'); + lines.push(' const objectType = OBJECT_TYPES[typeName];'); + lines.push(' if (!objectType) return undefined;'); + lines.push(' return objectType.attributes.find(a => a.fieldName === fieldName);'); + lines.push('}'); + lines.push(''); + + lines.push('/** Get attribute definition by type and Jira attribute ID */'); + lines.push('export function getAttributeById(typeName: string, jiraId: number): AttributeDefinition | undefined {'); + lines.push(' const objectType = OBJECT_TYPES[typeName];'); + lines.push(' if (!objectType) return undefined;'); + lines.push(' return objectType.attributes.find(a => a.jiraId === jiraId);'); + lines.push('}'); + lines.push(''); + + lines.push('/** Get attribute definition by type and Jira attribute name */'); + lines.push('export function getAttributeByName(typeName: string, attrName: string): AttributeDefinition | undefined {'); + lines.push(' const objectType = OBJECT_TYPES[typeName];'); + lines.push(' if (!objectType) return undefined;'); + lines.push(' return objectType.attributes.find(a => a.name === attrName);'); + lines.push('}'); + lines.push(''); + + lines.push('/** Get attribute Jira ID by type and attribute name - throws if not found */'); + lines.push('export function getAttributeId(typeName: string, attrName: string): number {'); + lines.push(' const attr = getAttributeByName(typeName, attrName);'); + lines.push(' if (!attr) {'); + lines.push(' throw new Error(`Attribute "${attrName}" not found on type "${typeName}"`);'); + lines.push(' }'); + lines.push(' return attr.jiraId;'); + lines.push('}'); + lines.push(''); + + lines.push('/** Get all reference attributes for a type */'); + lines.push('export function getReferenceAttributes(typeName: string): AttributeDefinition[] {'); + lines.push(' const objectType = OBJECT_TYPES[typeName];'); + lines.push(' if (!objectType) return [];'); + lines.push(" return objectType.attributes.filter(a => a.type === 'reference');"); + lines.push('}'); + lines.push(''); + + lines.push('/** Get all object types sorted by sync priority */'); + lines.push('export function getObjectTypesBySyncPriority(): ObjectTypeDefinition[] {'); + lines.push(' return Object.values(OBJECT_TYPES).sort((a, b) => a.syncPriority - b.syncPriority);'); + lines.push('}'); + lines.push(''); + + return lines.join('\n'); +} + +function generateDatabaseSchema(generatedAt: Date): string { + const lines: string[] = [ + '-- AUTO-GENERATED FILE - DO NOT EDIT MANUALLY', + '-- Generated from Jira Assets Schema via REST API', + `-- Generated at: ${generatedAt.toISOString()}`, + '--', + '-- Re-generate with: npm run generate-schema', + '', + '-- =============================================================================', + '-- Core Tables', + '-- =============================================================================', + '', + '-- Cached CMDB objects (all types stored in single table with JSON data)', + 'CREATE TABLE IF NOT EXISTS cached_objects (', + ' id TEXT PRIMARY KEY,', + ' object_key TEXT NOT NULL UNIQUE,', + ' object_type TEXT NOT NULL,', + ' label TEXT NOT NULL,', + ' data JSON NOT NULL,', + ' jira_updated_at TEXT,', + ' jira_created_at TEXT,', + ' cached_at TEXT NOT NULL', + ');', + '', + '-- Object relations (references between objects)', + 'CREATE TABLE IF NOT EXISTS object_relations (', + ' id INTEGER PRIMARY KEY AUTOINCREMENT,', + ' source_id TEXT NOT NULL,', + ' target_id TEXT NOT NULL,', + ' attribute_name TEXT NOT NULL,', + ' source_type TEXT NOT NULL,', + ' target_type TEXT NOT NULL,', + ' UNIQUE(source_id, target_id, attribute_name)', + ');', + '', + '-- Sync metadata (tracks sync state)', + 'CREATE TABLE IF NOT EXISTS sync_metadata (', + ' key TEXT PRIMARY KEY,', + ' value TEXT NOT NULL,', + ' updated_at TEXT NOT NULL', + ');', + '', + '-- =============================================================================', + '-- Indices for Performance', + '-- =============================================================================', + '', + 'CREATE INDEX IF NOT EXISTS idx_objects_type ON cached_objects(object_type);', + 'CREATE INDEX IF NOT EXISTS idx_objects_key ON cached_objects(object_key);', + 'CREATE INDEX IF NOT EXISTS idx_objects_updated ON cached_objects(jira_updated_at);', + 'CREATE INDEX IF NOT EXISTS idx_objects_label ON cached_objects(label);', + '', + 'CREATE INDEX IF NOT EXISTS idx_relations_source ON object_relations(source_id);', + 'CREATE INDEX IF NOT EXISTS idx_relations_target ON object_relations(target_id);', + 'CREATE INDEX IF NOT EXISTS idx_relations_source_type ON object_relations(source_type);', + 'CREATE INDEX IF NOT EXISTS idx_relations_target_type ON object_relations(target_type);', + 'CREATE INDEX IF NOT EXISTS idx_relations_attr ON object_relations(attribute_name);', + '', + ]; + + return lines.join('\n'); +} + +// ============================================================================= +// Main +// ============================================================================= + +async function main() { + const generatedAt = new Date(); + + console.log(''); + console.log('╔════════════════════════════════════════════════════════════════╗'); + console.log('║ CMDB Schema Generator - Jira Assets API ║'); + console.log('╚════════════════════════════════════════════════════════════════╝'); + console.log(''); + + // Validate configuration + if (!JIRA_HOST) { + console.error('❌ ERROR: JIRA_HOST environment variable is required'); + console.error(' Set this in your .env file: JIRA_HOST=https://jira.your-domain.com'); + process.exit(1); + } + + if (!JIRA_PAT) { + console.error('❌ ERROR: JIRA_PAT environment variable is required'); + console.error(' Set this in your .env file: JIRA_PAT=your-personal-access-token'); + process.exit(1); + } + + if (!JIRA_SCHEMA_ID) { + console.error('❌ ERROR: JIRA_SCHEMA_ID environment variable is required'); + console.error(' Set this in your .env file: JIRA_SCHEMA_ID=6'); + process.exit(1); + } + + if (envLoaded) { + console.log(`🔧 Environment: ${envLoaded}`); + } + console.log(`📡 Jira Host: ${JIRA_HOST}`); + console.log(`📋 Schema ID: ${JIRA_SCHEMA_ID}`); + console.log(`📁 Output Dir: ${OUTPUT_DIR}`); + console.log(''); + + // Ensure output directory exists + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + console.log(`📁 Created output directory: ${OUTPUT_DIR}`); + } + + const fetcher = new JiraSchemaFetcher(JIRA_HOST, JIRA_PAT); + + // Test connection + console.log('🔌 Testing connection to Jira Assets API...'); + const connected = await fetcher.testConnection(); + if (!connected) { + console.error('❌ Failed to connect to Jira Assets API'); + console.error(' Please check your JIRA_HOST and JIRA_PAT settings'); + process.exit(1); + } + console.log('✅ Connection successful'); + console.log(''); + + // Fetch schema info + console.log('📋 Fetching schema information...'); + const schema = await fetcher.fetchSchema(JIRA_SCHEMA_ID); + if (!schema) { + console.error(`❌ Failed to fetch schema ${JIRA_SCHEMA_ID}`); + process.exit(1); + } + console.log(` Schema: ${schema.name} (${schema.objectSchemaKey})`); + console.log(` Total objects: ${schema.objectCount || 'unknown'}`); + console.log(''); + + // Fetch ALL object types from the schema + console.log('📦 Fetching all object types from schema...'); + const allObjectTypes = await fetcher.fetchAllObjectTypes(JIRA_SCHEMA_ID); + + if (allObjectTypes.length === 0) { + console.error('❌ No object types found in schema'); + process.exit(1); + } + + console.log(` Found ${allObjectTypes.length} object types`); + console.log(''); + + // Build a map of all type IDs to names for reference resolution + const typeConfigs = new Map(); + for (const ot of allObjectTypes) { + typeConfigs.set(ot.id, { + name: ot.name, + typeName: toPascalCase(ot.name), + }); + } + + // Fetch attributes for each object type + console.log('🔍 Fetching attributes for each object type:'); + console.log(''); + + const generatedTypes: GeneratedObjectType[] = []; + let totalAttributes = 0; + + for (const objType of allObjectTypes) { + const typeName = toPascalCase(objType.name); + process.stdout.write(` ${objType.name.padEnd(50)} `); + + const attributes = await fetcher.fetchAttributes(objType.id); + + if (attributes.length === 0) { + console.log('⚠️ (no attributes)'); + continue; + } + + const parsedAttributes = attributes.map(attr => parseAttribute(attr, typeConfigs)); + const syncPriority = determineSyncPriority(objType.name, objType.objectCount || 0); + + generatedTypes.push({ + jiraTypeId: objType.id, + name: objType.name, + typeName, + syncPriority, + objectCount: objType.objectCount || 0, + attributes: parsedAttributes, + }); + + totalAttributes += attributes.length; + console.log(`✅ ${attributes.length} attributes`); + } + + console.log(''); + console.log(`📊 Summary: ${generatedTypes.length} types, ${totalAttributes} attributes total`); + console.log(''); + + // Sort by sync priority + generatedTypes.sort((a, b) => a.syncPriority - b.syncPriority); + + // Generate files + console.log('📝 Generating output files:'); + console.log(''); + + // 1. TypeScript types + const typesContent = generateTypesFile(generatedTypes, generatedAt); + const typesPath = path.join(OUTPUT_DIR, 'jira-types.ts'); + fs.writeFileSync(typesPath, typesContent, 'utf-8'); + console.log(` ✅ ${typesPath}`); + + // 2. Schema metadata + const schemaContent = generateSchemaFile(generatedTypes, generatedAt); + const schemaPath = path.join(OUTPUT_DIR, 'jira-schema.ts'); + fs.writeFileSync(schemaPath, schemaContent, 'utf-8'); + console.log(` ✅ ${schemaPath}`); + + // 3. Database DDL + const dbContent = generateDatabaseSchema(generatedAt); + const dbPath = path.join(OUTPUT_DIR, 'db-schema.sql'); + fs.writeFileSync(dbPath, dbContent, 'utf-8'); + console.log(` ✅ ${dbPath}`); + + console.log(''); + console.log('╔════════════════════════════════════════════════════════════════╗'); + console.log('║ Schema generation complete! ║'); + console.log('╚════════════════════════════════════════════════════════════════╝'); + console.log(''); + console.log(`Generated at: ${generatedAt.toISOString()}`); + console.log(`Object types: ${generatedTypes.length}`); + console.log(`Attributes: ${totalAttributes}`); + console.log(''); + console.log('Next steps:'); + console.log(' 1. Review the generated files in src/generated/'); + console.log(' 2. Rebuild the application: npm run build'); + console.log(' 3. Restart the server to pick up the new schema'); + console.log(''); +} + +// Run +main().catch(error => { + console.error(''); + console.error('❌ Schema generation failed:', error); + console.error(''); + process.exit(1); +}); diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 549b473..e5be63f 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -1,17 +1,42 @@ import dotenv from 'dotenv'; import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; -// Load .env from project root -dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); +// Get __dirname equivalent for ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Try multiple possible .env locations (handles both tsx watch and compiled dist) +const possibleEnvPaths = [ + path.resolve(process.cwd(), '.env'), // Project root from cwd + path.resolve(__dirname, '../../../.env'), // From src/config/ to project root + path.resolve(__dirname, '../../../../.env'), // From dist/config/ to project root +]; + +for (const envPath of possibleEnvPaths) { + if (fs.existsSync(envPath)) { + dotenv.config({ path: envPath }); + console.log(`Environment loaded from: ${envPath}`); + break; + } +} + +// Authentication method type +export type JiraAuthMethod = 'pat' | 'oauth'; interface Config { // Jira Assets jiraHost: string; - jiraPat: string; jiraSchemaId: string; - // Jira OAuth 2.0 Configuration - jiraOAuthEnabled: boolean; + // Jira Authentication Method ('pat' or 'oauth') + jiraAuthMethod: JiraAuthMethod; + + // Jira Personal Access Token (used when jiraAuthMethod = 'pat') + jiraPat: string; + + // Jira OAuth 2.0 Configuration (used when jiraAuthMethod = 'oauth') jiraOAuthClientId: string; jiraOAuthClientSecret: string; jiraOAuthCallbackUrl: string; @@ -20,38 +45,6 @@ interface Config { // Session Configuration sessionSecret: string; - // Object Type IDs - jiraApplicationComponentTypeId: string; - jiraApplicationFunctionTypeId: string; - jiraDynamicsFactorTypeId: string; - jiraComplexityFactorTypeId: string; - jiraNumberOfUsersTypeId: string; - jiraGovernanceModelTypeId: string; - jiraApplicationClusterTypeId: string; - jiraApplicationTypeTypeId: string; - jiraHostingTypeTypeId: string; - jiraBusinessImpactAnalyseTypeId: string; - jiraApplicationManagementHostingTypeId: string; // Object Type ID for "Application Management - Hosting" - jiraApplicationManagementTAMTypeId: string; // Object Type ID for "Application Management - TAM" - - // Attribute IDs - jiraAttrApplicationFunction: string; - jiraAttrDynamicsFactor: string; - jiraAttrComplexityFactor: string; - jiraAttrNumberOfUsers: string; - jiraAttrGovernanceModel: string; - jiraAttrApplicationCluster: string; - jiraAttrApplicationType: string; - jiraAttrPlatform: string; - jiraAttrHostingType: string; - jiraAttrBusinessImpactAnalyse: string; - jiraAttrTechnischeArchitectuur: string; // Attribute ID for "Technische Architectuur (TA)" - jiraAttrTechnicalApplicationManagementPrimary: string; // Attribute ID for "Technical Application Management Primary" - jiraAttrTechnicalApplicationManagementSecondary: string; // Attribute ID for "Technical Application Management Secondary" - jiraAttrOverrideFTE: string; // Attribute ID for "Application Management - Override FTE" - jiraAttrApplicationManagementHosting: string; // Attribute ID for "Application Management - Hosting" (4939) - jiraAttrApplicationManagementTAM: string; // Attribute ID for "Application Management - TAM" (4945) - // AI API Keys anthropicApiKey: string; openaiApiKey: string; @@ -72,26 +65,44 @@ interface Config { jiraApiBatchSize: number; } -function getEnvVar(name: string, defaultValue?: string): string { - const value = process.env[name] || defaultValue; - if (!value) { - throw new Error(`Environment variable ${name} is required but not set`); - } - return value; -} - function getOptionalEnvVar(name: string, defaultValue: string = ''): string { return process.env[name] || defaultValue; } +// Helper to determine auth method with backward compatibility +function getJiraAuthMethod(): JiraAuthMethod { + // Check new JIRA_AUTH_METHOD first + const authMethod = getOptionalEnvVar('JIRA_AUTH_METHOD', '').toLowerCase(); + if (authMethod === 'oauth') return 'oauth'; + if (authMethod === 'pat') return 'pat'; + + // Backward compatibility: check JIRA_OAUTH_ENABLED + const oauthEnabled = getOptionalEnvVar('JIRA_OAUTH_ENABLED', 'false').toLowerCase() === 'true'; + if (oauthEnabled) return 'oauth'; + + // Default to 'pat' if JIRA_PAT is set, otherwise 'oauth' if OAuth credentials exist + const hasPat = !!getOptionalEnvVar('JIRA_PAT'); + const hasOAuthCredentials = !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID') && !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'); + + if (hasPat) return 'pat'; + if (hasOAuthCredentials) return 'oauth'; + + // Default to 'pat' (will show warning during validation) + return 'pat'; +} + export const config: Config = { // Jira Assets jiraHost: getOptionalEnvVar('JIRA_HOST', 'https://jira.zuyderland.nl'), - jiraPat: getOptionalEnvVar('JIRA_PAT'), jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'), - // Jira OAuth 2.0 Configuration - jiraOAuthEnabled: getOptionalEnvVar('JIRA_OAUTH_ENABLED', 'false').toLowerCase() === 'true', + // Jira Authentication Method + jiraAuthMethod: getJiraAuthMethod(), + + // Jira Personal Access Token (for PAT authentication) + jiraPat: getOptionalEnvVar('JIRA_PAT'), + + // Jira OAuth 2.0 Configuration (for OAuth authentication) jiraOAuthClientId: getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID'), jiraOAuthClientSecret: getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'), jiraOAuthCallbackUrl: getOptionalEnvVar('JIRA_OAUTH_CALLBACK_URL', 'http://localhost:3001/api/auth/callback'), @@ -100,38 +111,6 @@ export const config: Config = { // Session Configuration sessionSecret: getOptionalEnvVar('SESSION_SECRET', 'change-this-secret-in-production'), - // Object Type IDs - jiraApplicationComponentTypeId: getOptionalEnvVar('JIRA_APPLICATION_COMPONENT_TYPE_ID'), - jiraApplicationFunctionTypeId: getOptionalEnvVar('JIRA_APPLICATION_FUNCTION_TYPE_ID'), - jiraDynamicsFactorTypeId: getOptionalEnvVar('JIRA_DYNAMICS_FACTOR_TYPE_ID'), - jiraComplexityFactorTypeId: getOptionalEnvVar('JIRA_COMPLEXITY_FACTOR_TYPE_ID'), - jiraNumberOfUsersTypeId: getOptionalEnvVar('JIRA_NUMBER_OF_USERS_TYPE_ID'), - jiraGovernanceModelTypeId: getOptionalEnvVar('JIRA_GOVERNANCE_MODEL_TYPE_ID'), - jiraApplicationClusterTypeId: getOptionalEnvVar('JIRA_APPLICATION_CLUSTER_TYPE_ID'), - jiraApplicationTypeTypeId: getOptionalEnvVar('JIRA_APPLICATION_TYPE_TYPE_ID'), - jiraHostingTypeTypeId: getOptionalEnvVar('JIRA_HOSTING_TYPE_TYPE_ID', '39'), - jiraBusinessImpactAnalyseTypeId: getOptionalEnvVar('JIRA_BUSINESS_IMPACT_ANALYSE_TYPE_ID', '41'), - jiraApplicationManagementHostingTypeId: getOptionalEnvVar('JIRA_APPLICATION_MANAGEMENT_HOSTING_TYPE_ID', '438'), - jiraApplicationManagementTAMTypeId: getOptionalEnvVar('JIRA_APPLICATION_MANAGEMENT_TAM_TYPE_ID', '439'), - - // Attribute IDs - jiraAttrApplicationFunction: getOptionalEnvVar('JIRA_ATTR_APPLICATION_FUNCTION'), - jiraAttrDynamicsFactor: getOptionalEnvVar('JIRA_ATTR_DYNAMICS_FACTOR'), - jiraAttrComplexityFactor: getOptionalEnvVar('JIRA_ATTR_COMPLEXITY_FACTOR'), - jiraAttrNumberOfUsers: getOptionalEnvVar('JIRA_ATTR_NUMBER_OF_USERS'), - jiraAttrGovernanceModel: getOptionalEnvVar('JIRA_ATTR_GOVERNANCE_MODEL'), - jiraAttrApplicationCluster: getOptionalEnvVar('JIRA_ATTR_APPLICATION_CLUSTER'), - jiraAttrApplicationType: getOptionalEnvVar('JIRA_ATTR_APPLICATION_TYPE'), - jiraAttrPlatform: getOptionalEnvVar('JIRA_ATTR_PLATFORM'), - jiraAttrHostingType: getOptionalEnvVar('JIRA_ATTR_HOSTING_TYPE', '355'), - jiraAttrBusinessImpactAnalyse: getOptionalEnvVar('JIRA_ATTR_BUSINESS_IMPACT_ANALYSE', '368'), - jiraAttrTechnischeArchitectuur: getOptionalEnvVar('JIRA_ATTR_TECHNISCHE_ARCHITECTUUR', '572'), - jiraAttrTechnicalApplicationManagementPrimary: getOptionalEnvVar('JIRA_ATTR_TECHNICAL_APPLICATION_MANAGEMENT_PRIMARY', '377'), - jiraAttrTechnicalApplicationManagementSecondary: getOptionalEnvVar('JIRA_ATTR_TECHNICAL_APPLICATION_MANAGEMENT_SECONDARY', '1330'), - jiraAttrOverrideFTE: getOptionalEnvVar('JIRA_ATTR_OVERRIDE_FTE', '4932'), - jiraAttrApplicationManagementHosting: getOptionalEnvVar('JIRA_ATTR_APPLICATION_MANAGEMENT_HOSTING', '4939'), - jiraAttrApplicationManagementTAM: getOptionalEnvVar('JIRA_ATTR_APPLICATION_MANAGEMENT_TAM', '4945'), - // AI API Keys anthropicApiKey: getOptionalEnvVar('ANTHROPIC_API_KEY'), openaiApiKey: getOptionalEnvVar('OPENAI_API_KEY'), @@ -154,10 +133,34 @@ export const config: Config = { export function validateConfig(): void { const missingVars: string[] = []; + const warnings: string[] = []; - if (!config.jiraPat) missingVars.push('JIRA_PAT'); + // Validate authentication configuration based on selected method + console.log(`Jira Authentication Method: ${config.jiraAuthMethod.toUpperCase()}`); + + if (config.jiraAuthMethod === 'pat') { + if (!config.jiraPat) { + missingVars.push('JIRA_PAT (required for PAT authentication)'); + } + } else if (config.jiraAuthMethod === 'oauth') { + if (!config.jiraOAuthClientId) { + missingVars.push('JIRA_OAUTH_CLIENT_ID (required for OAuth authentication)'); + } + if (!config.jiraOAuthClientSecret) { + missingVars.push('JIRA_OAUTH_CLIENT_SECRET (required for OAuth authentication)'); + } + if (!config.sessionSecret || config.sessionSecret === 'change-this-secret-in-production') { + warnings.push('SESSION_SECRET should be set to a secure random value for OAuth sessions'); + } + } + + // General required config if (!config.jiraSchemaId) missingVars.push('JIRA_SCHEMA_ID'); - if (!config.anthropicApiKey) missingVars.push('ANTHROPIC_API_KEY'); + if (!config.anthropicApiKey) warnings.push('ANTHROPIC_API_KEY not set - AI classification disabled'); + + if (warnings.length > 0) { + warnings.forEach(w => console.warn(`Warning: ${w}`)); + } if (missingVars.length > 0) { console.warn(`Warning: Missing environment variables: ${missingVars.join(', ')}`); diff --git a/backend/src/generated/db-schema.sql b/backend/src/generated/db-schema.sql new file mode 100644 index 0000000..aff3a98 --- /dev/null +++ b/backend/src/generated/db-schema.sql @@ -0,0 +1,54 @@ +-- AUTO-GENERATED FILE - DO NOT EDIT MANUALLY +-- Generated from Jira Assets Schema via REST API +-- Generated at: 2026-01-09T02:12:50.973Z +-- +-- Re-generate with: npm run generate-schema + +-- ============================================================================= +-- Core Tables +-- ============================================================================= + +-- Cached CMDB objects (all types stored in single table with JSON data) +CREATE TABLE IF NOT EXISTS cached_objects ( + id TEXT PRIMARY KEY, + object_key TEXT NOT NULL UNIQUE, + object_type TEXT NOT NULL, + label TEXT NOT NULL, + data JSON NOT NULL, + jira_updated_at TEXT, + jira_created_at TEXT, + cached_at TEXT NOT NULL +); + +-- Object relations (references between objects) +CREATE TABLE IF NOT EXISTS object_relations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + attribute_name TEXT NOT NULL, + source_type TEXT NOT NULL, + target_type TEXT NOT NULL, + UNIQUE(source_id, target_id, attribute_name) +); + +-- Sync metadata (tracks sync state) +CREATE TABLE IF NOT EXISTS sync_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- ============================================================================= +-- Indices for Performance +-- ============================================================================= + +CREATE INDEX IF NOT EXISTS idx_objects_type ON cached_objects(object_type); +CREATE INDEX IF NOT EXISTS idx_objects_key ON cached_objects(object_key); +CREATE INDEX IF NOT EXISTS idx_objects_updated ON cached_objects(jira_updated_at); +CREATE INDEX IF NOT EXISTS idx_objects_label ON cached_objects(label); + +CREATE INDEX IF NOT EXISTS idx_relations_source ON object_relations(source_id); +CREATE INDEX IF NOT EXISTS idx_relations_target ON object_relations(target_id); +CREATE INDEX IF NOT EXISTS idx_relations_source_type ON object_relations(source_type); +CREATE INDEX IF NOT EXISTS idx_relations_target_type ON object_relations(target_type); +CREATE INDEX IF NOT EXISTS idx_relations_attr ON object_relations(attribute_name); diff --git a/backend/src/generated/jira-schema.ts b/backend/src/generated/jira-schema.ts new file mode 100644 index 0000000..79c8c97 --- /dev/null +++ b/backend/src/generated/jira-schema.ts @@ -0,0 +1,894 @@ +// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY +// Generated from Jira Assets Schema via REST API +// Generated at: 2026-01-09T02:12:50.973Z +// +// Re-generate with: npm run generate-schema + +// ============================================================================= +// Schema Type Definitions +// ============================================================================= + +export interface AttributeDefinition { + jiraId: number; + name: string; + fieldName: string; + type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown'; + isMultiple: boolean; + isEditable: boolean; + isRequired: boolean; + isSystem: boolean; + referenceTypeId?: number; + referenceTypeName?: string; + description?: string; +} + +export interface ObjectTypeDefinition { + jiraTypeId: number; + name: string; + typeName: string; + syncPriority: number; + objectCount: number; + attributes: AttributeDefinition[]; +} + +// ============================================================================= +// Schema Metadata +// ============================================================================= + +export const SCHEMA_GENERATED_AT = '2026-01-09T02:12:50.973Z'; +export const SCHEMA_OBJECT_TYPE_COUNT = 35; +export const SCHEMA_TOTAL_ATTRIBUTES = 365; + +// ============================================================================= +// Object Type Definitions +// ============================================================================= + +export const OBJECT_TYPES: Record = { + 'ApplicationComponent': { + jiraTypeId: 38, + name: 'Application Component', + typeName: 'ApplicationComponent', + syncPriority: 1, + objectCount: 596, + attributes: [ + { jiraId: 569, name: 'Reference', fieldName: 'reference', type: 'text', isMultiple: false, isEditable: false, isRequired: false, isSystem: false, description: 'Niet aanpassen. GUID - Enterprise Architect' }, + { jiraId: 341, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 425, name: 'SearchReference', fieldName: 'searchReference', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Additionele zoekwoorden t.b.v. search' }, + { jiraId: 342, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'Unieke naam object' }, + { jiraId: 343, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 344, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 354, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: '* Application description' }, + { jiraId: 4538, name: 'Organisation', fieldName: 'organisation', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 390, referenceTypeName: 'Organisation' }, + { jiraId: 4666, name: 'ApplicationFunction', fieldName: 'applicationFunction', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 403, referenceTypeName: 'ApplicationFunction' }, + { jiraId: 2416, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Application Lifecycle Management' }, + { jiraId: 416, name: 'Confluence Space', fieldName: 'confluenceSpace', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 399, name: 'Business Importance', fieldName: 'businessImportance', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 44, referenceTypeName: 'BusinessImportance' }, + { jiraId: 4540, name: 'Zenya ID', fieldName: 'zenyaID', type: 'integer', isMultiple: false, isEditable: false, isRequired: false, isSystem: false }, + { jiraId: 4615, name: 'Zenya URL', fieldName: 'zenyaURL', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 368, name: 'Business Impact Analyse', fieldName: 'businessImpactAnalyse', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 41, referenceTypeName: 'BusinessImpactAnalyse' }, + { jiraId: 355, name: 'Application Component Hosting Type', fieldName: 'applicationComponentHostingType', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 39, referenceTypeName: 'HostingType' }, + { jiraId: 394, name: 'CustomDevelopment', fieldName: 'customDevelopment', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Is er sprake van eigen programmatuur?' }, + { jiraId: 4927, name: 'Platform', fieldName: 'platform', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' }, + { jiraId: 358, name: 'Referenced Application Component', fieldName: 'referencedApplicationComponent', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: 'Welk application component maakt onderdeel uit van een ander Application Component' }, + { jiraId: 359, name: 'Authentication Method', fieldName: 'authenticationMethod', type: 'reference', isMultiple: false, isEditable: false, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: '*HIDDEN* zie CMDB-488' }, + { jiraId: 362, name: 'Monitoring', fieldName: 'monitoring', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: 'Cross reference naar Application Component' }, + { jiraId: 373, name: 'PII Data', fieldName: 'piiData', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Maakt applicatie gebruik van Persoonlijk identificeerbare informatie?' }, + { jiraId: 374, name: 'Medical Data', fieldName: 'medicalData', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Maakt de Application Component gebruik van medische data?' }, + { jiraId: 363, name: 'Supplier Product', fieldName: 'supplierProduct', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier', description: 'Wie is de leverancier van de Application Component?' }, + { jiraId: 364, name: 'Supplier Technical', fieldName: 'supplierTechnical', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' }, + { jiraId: 365, name: 'Supplier Implementation', fieldName: 'supplierImplementation', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier', description: 'Wie is leverancier van de implementatie?' }, + { jiraId: 366, name: 'Supplier Consultancy', fieldName: 'supplierConsultancy', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' }, + { jiraId: 2365, name: 'Business Owner', fieldName: 'businessOwner', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 243, referenceTypeName: 'Organization Unit', description: 'Verantwoordelijk voor waarde en bedrijfsvoering van het systeem' }, + { jiraId: 2366, name: 'System Owner', fieldName: 'systemOwner', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 103, referenceTypeName: 'User', description: 'Verantwoordelijk voor technische werking en beheer van het systeem.' }, + { jiraId: 2371, name: 'Functional Application Management', fieldName: 'functionalApplicationManagement', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4613, name: 'Technical Application Management', fieldName: 'technicalApplicationManagement', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 400, referenceTypeName: 'group' }, + { jiraId: 377, name: 'Technical Application Management Primary', fieldName: 'technicalApplicationManagementPrimary', type: 'boolean', isMultiple: true, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 1330, name: 'Technical Application Management Secondary', fieldName: 'technicalApplicationManagementSecondary', type: 'boolean', isMultiple: true, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 1331, name: 'Medische Techniek', fieldName: 'medischeTechniek', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Is er een link met medische techniek' }, + { jiraId: 572, name: 'Technische Architectuur (TA)', fieldName: 'technischeArchitectuurTA', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Komt uit Enterprise Architect mee' }, + { jiraId: 4497, name: 'Measures', fieldName: 'measures', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4749, name: 'GenerateConfluenceSpace', fieldName: 'generateConfluenceSpace', type: 'boolean', isMultiple: false, isEditable: false, isRequired: false, isSystem: false, description: 'Wordt gebruikt door script om space te genereren - niet verwijderen. Attribuut is hidden' }, + { jiraId: 4793, name: 'SourceStatus', fieldName: 'sourceStatus', type: 'reference', isMultiple: false, isEditable: false, isRequired: false, isSystem: false }, + { jiraId: 4799, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4906, name: 'ICT Governance Model', fieldName: 'ictGovernanceModel', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 431, referenceTypeName: 'IctGovernanceModel' }, + { jiraId: 4918, name: 'Application Management - Application Type', fieldName: 'applicationManagementApplicationType', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 436, referenceTypeName: 'ApplicationManagementApplicationType', description: 'De Type Classificatie bepaalt de aard en scope van het IT-object.' }, + { jiraId: 4939, name: 'Application Management - Hosting', fieldName: 'applicationManagementHosting', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 438, referenceTypeName: 'ApplicationManagementHosting', description: 'Het Hosting-veld geeft aan waar de infrastructuur draait. Dit bepaalt mede de technische verantwoordelijkheden en compliance-eisen.' }, + { jiraId: 4945, name: 'Application Management - TAM', fieldName: 'applicationManagementTAM', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 439, referenceTypeName: 'ApplicationManagementTam', description: 'Dit veld geeft aan wie het technisch applicatiebeheer uitvoert. Dit is de primaire factor voor het bepalen van het regiemodel.' }, + { jiraId: 4903, name: 'Application Management - Dynamics Factor', fieldName: 'applicationManagementDynamicsFactor', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 434, referenceTypeName: 'ApplicationManagementDynamicsFactor' }, + { jiraId: 4904, name: 'Application Management - Complexity Factor', fieldName: 'applicationManagementComplexityFactor', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 432, referenceTypeName: 'ApplicationManagementComplexityFactor' }, + { jiraId: 4905, name: 'Application Management - Number of Users', fieldName: 'applicationManagementNumberOfUsers', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 433, referenceTypeName: 'ApplicationManagementNumberOfUsers' }, + { jiraId: 4911, name: 'Application Management - Subteam', fieldName: 'applicationManagementSubteam', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 435, referenceTypeName: 'ApplicationManagementSubteam' }, + { jiraId: 4932, name: 'Application Management - Override FTE', fieldName: 'applicationManagementOverrideFTE', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'Flows': { + jiraTypeId: 59, + name: 'Flows', + typeName: 'Flows', + syncPriority: 1, + objectCount: 903, + attributes: [ + { jiraId: 1104, name: 'Reference', fieldName: 'reference', type: 'text', isMultiple: false, isEditable: false, isRequired: false, isSystem: false }, + { jiraId: 1105, name: 'Present In Import Enterprise Architect', fieldName: 'presentInImportEnterpriseArchitect', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 558, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 559, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 567, name: 'Source', fieldName: 'source', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: 'Application Component - Source' }, + { jiraId: 4535, name: 'Search Reference Source', fieldName: 'searchReferenceSource', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 568, name: 'Target', fieldName: 'target', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: 'Application Component - Target' }, + { jiraId: 4536, name: 'Search Reference Target', fieldName: 'searchReferenceTarget', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 564, name: 'Type', fieldName: 'type', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 565, name: 'Protocol', fieldName: 'protocol', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 566, name: 'Details', fieldName: 'details', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 570, name: 'Broker', fieldName: 'broker', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 571, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 560, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 561, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4801, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'Server': { + jiraTypeId: 48, + name: 'Server', + typeName: 'Server', + syncPriority: 1, + objectCount: 909, + attributes: [ + { jiraId: 417, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 418, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 450, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 441, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4500, name: 'VMLocation', fieldName: 'vMLocation', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4501, name: 'Application Component', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' }, + { jiraId: 4762, name: 'AzureSubscription', fieldName: 'azureSubscription', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 418, referenceTypeName: 'AzureSubscription' }, + { jiraId: 455, name: 'VMOSType', fieldName: 'vMOSType', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 2406, name: 'Reboot group', fieldName: 'rebootGroup', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 251, referenceTypeName: 'Rebootgroups' }, + { jiraId: 444, name: 'MemoryMB', fieldName: 'memoryMB', type: 'integer', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 2383, name: 'CPUCores', fieldName: 'cPUCores', type: 'integer', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 2384, name: 'NICCount', fieldName: 'nICCount', type: 'integer', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 2385, name: 'DataDisks', fieldName: 'dataDisks', type: 'integer', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4720, name: 'PrivateIPAddress', fieldName: 'privateIPAddress', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4764, name: 'Cluster', fieldName: 'cluster', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4765, name: 'VMSize', fieldName: 'vMSize', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 419, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 420, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4592, name: 'DBA', fieldName: 'dBA', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4792, name: 'SourceStatus', fieldName: 'sourceStatus', type: 'reference', isMultiple: false, isEditable: false, isRequired: false, isSystem: false }, + { jiraId: 4802, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4873, name: 'State', fieldName: 'state', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'AzureSubscription': { + jiraTypeId: 418, + name: 'AzureSubscription', + typeName: 'AzureSubscription', + syncPriority: 2, + objectCount: 151, + attributes: [ + { jiraId: 4755, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4756, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the subscription' }, + { jiraId: 4761, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4760, name: 'Server', fieldName: 'server', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 48, referenceTypeName: 'Server' }, + { jiraId: 4759, name: 'ApplicationComponent', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' }, + { jiraId: 4757, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4758, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4803, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'PackageBuild': { + jiraTypeId: 424, + name: 'PackageBuild', + typeName: 'PackageBuild', + syncPriority: 2, + objectCount: 496, + attributes: [ + { jiraId: 4816, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4817, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'naamgeving: software-versie' }, + { jiraId: 4820, name: 'Software', fieldName: 'software', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 421, referenceTypeName: 'Software' }, + { jiraId: 4821, name: 'Version', fieldName: 'version', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4835, name: 'Status', fieldName: 'status', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4818, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4819, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4839, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4824, name: 'BuildNumber', fieldName: 'buildNumber', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4825, name: 'Architecture', fieldName: 'architecture', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4826, name: 'Environment', fieldName: 'environment', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4827, name: 'Language', fieldName: 'language', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4828, name: 'AppDelivery', fieldName: 'appDelivery', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4829, name: 'ADlocalMemberships', fieldName: 'aDlocalMemberships', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4830, name: 'ADmemberships', fieldName: 'aDmemberships', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4831, name: 'EntraIDmemberships', fieldName: 'entraIDmemberships', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4832, name: 'Platform', fieldName: 'platform', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4833, name: 'InGoldenImage', fieldName: 'inGoldenImage', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4837, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'Package': { + jiraTypeId: 422, + name: 'Package', + typeName: 'Package', + syncPriority: 2, + objectCount: 299, + attributes: [ + { jiraId: 4806, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4807, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4808, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4809, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4836, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4810, name: 'ApplicationComponent', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' }, + { jiraId: 4811, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4812, name: 'SupplierProduct', fieldName: 'supplierProduct', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' }, + { jiraId: 4813, name: 'SupplierTechnical', fieldName: 'supplierTechnical', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' }, + { jiraId: 4814, name: 'BusinessImportance', fieldName: 'businessImportance', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4815, name: 'Authentication', fieldName: 'authentication', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4834, name: 'MaintenanceContract', fieldName: 'maintenanceContract', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4838, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'Certificate': { + jiraTypeId: 406, + name: 'Certificate', + typeName: 'Certificate', + syncPriority: 2, + objectCount: 508, + attributes: [ + { jiraId: 4675, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4676, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'Naam van het certificaat' }, + { jiraId: 4686, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'Status is Active, Closed, Unknown (date 1970-01-01) or Support Requested (ticket aangemaakt vanwege Expiry Date)' }, + { jiraId: 4695, name: 'Type', fieldName: 'type', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 407, referenceTypeName: 'CertificateType', description: 'Type certificaat' }, + { jiraId: 4696, name: 'Classification Type', fieldName: 'classificationType', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 408, referenceTypeName: 'CertificateClassificationType', description: 'Classificatie type' }, + { jiraId: 4698, name: 'Requester', fieldName: 'requester', type: 'boolean', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'De aanvrager (medewerker) van het betreffende certificaat' }, + { jiraId: 4682, name: 'Issuing Authority', fieldName: 'issuingAuthority', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier', description: 'Het bedrijf of de organisatie/instantie die verantwoordelijk is voor de uitgifte van het certificaat' }, + { jiraId: 4702, name: 'Issuing Supplier', fieldName: 'issuingSupplier', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier', description: 'Het bedrijf of de organisatie/instantie waar het certificaat is besteld.' }, + { jiraId: 4697, name: 'Autorenew', fieldName: 'autorenew', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Indien een expiry date beschikbaar, dan op False' }, + { jiraId: 4721, name: 'Expiry Date', fieldName: 'expiryDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: ' Als er geen Expiry Date bekend is, wordt de waarde ingesteld op 01-01-1970' }, + { jiraId: 4753, name: 'ReminderInDays', fieldName: 'reminderInDays', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'Aantal dagen vanaf welk moment er een reminder mechanisme wordt gestart' }, + { jiraId: 4797, name: 'ReminderMailbox', fieldName: 'reminderMailbox', type: 'boolean', isMultiple: true, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4683, name: 'Certificate Owner', fieldName: 'certificateOwner', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 243, referenceTypeName: 'Organization Unit', description: 'De (eind)verantwoordelijke (medewerker) van het certificaat binnen Zuyderland' }, + { jiraId: 4699, name: 'IT Operations Team', fieldName: 'itOperationsTeam', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 400, referenceTypeName: 'group', description: 'Het team dat verantwoordelijk is voor de installatie / configuratie certificaat' }, + { jiraId: 4700, name: 'Application Management', fieldName: 'applicationManagement', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 400, referenceTypeName: 'group', description: 'Het team dat verantwoordelijk is voor het applicatiebeheer van de gekoppelde Application Component / Dienst' }, + { jiraId: 4701, name: 'Application Component', fieldName: 'applicationComponent', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: 'Verwijzing naar Application Component' }, + { jiraId: 4680, name: 'Domain', fieldName: 'domain', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 252, referenceTypeName: 'Domain', description: 'Verwijzing naar Domain' }, + { jiraId: 4681, name: 'Server', fieldName: 'server', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 48, referenceTypeName: 'Server', description: 'Verwijzing naar Server' }, + { jiraId: 4679, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Algemene omschrijving' }, + { jiraId: 4719, name: 'Extra Installatie', fieldName: 'extraInstallatie', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' }, + { jiraId: 4677, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4678, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true } + ], + }, + 'PrivilegedUser': { + jiraTypeId: 401, + name: 'Privileged User', + typeName: 'PrivilegedUser', + syncPriority: 2, + objectCount: 728, + attributes: [ + { jiraId: 4616, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4617, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4620, name: 'UPN', fieldName: 'uPN', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'User Principal Name' }, + { jiraId: 4621, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Description' }, + { jiraId: 4622, name: 'Topdesk ticket', fieldName: 'topdeskTicket', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4623, name: 'Application Component', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' }, + { jiraId: 4624, name: 'E-Mail', fieldName: 'eMail', type: 'textarea', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4625, name: 'Mobile', fieldName: 'mobile', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4626, name: 'MethodsRegistered', fieldName: 'methodsRegistered', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4627, name: 'Creation Date', fieldName: 'creationDate', type: 'url', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4628, name: 'Expiry Date', fieldName: 'expiryDate', type: 'url', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4629, name: 'Last Updated', fieldName: 'lastUpdated', type: 'url', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4630, name: 'Password Last Set', fieldName: 'passwordLastSet', type: 'url', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4631, name: 'Password never expires', fieldName: 'passwordNeverExpires', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4632, name: 'MFA Registered', fieldName: 'mfaRegistered', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4633, name: 'Self-Service Password Reset Used', fieldName: 'selfServicePasswordResetUsed', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4653, name: 'Self-Service Password Reset Enabled', fieldName: 'selfServicePasswordResetEnabled', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4634, name: 'Last Logon', fieldName: 'lastLogon', type: 'url', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4618, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4619, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4652, name: 'SID', fieldName: 'sID', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4800, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'Domain': { + jiraTypeId: 252, + name: 'Domain', + typeName: 'Domain', + syncPriority: 2, + objectCount: 796, + attributes: [ + { jiraId: 2398, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 2399, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 2465, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 2466, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4672, name: 'Registration date', fieldName: 'registrationDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4668, name: 'Domain Type', fieldName: 'domainType', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 2471, name: 'Domain Function', fieldName: 'domainFunction', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4669, name: 'DNS Management', fieldName: 'dnsManagement', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' }, + { jiraId: 4673, name: 'Application Component', fieldName: 'applicationComponent', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' }, + { jiraId: 2467, name: 'Application Component Monitoring', fieldName: 'applicationComponentMonitoring', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' }, + { jiraId: 4543, name: 'Website availability', fieldName: 'websiteAvailability', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Intern/extern/local' }, + { jiraId: 4685, name: 'Redirect URL', fieldName: 'redirectURL', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4674, name: 'Mail enabled', fieldName: 'mailEnabled', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 2472, name: 'Business Owner', fieldName: 'businessOwner', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 243, referenceTypeName: 'Organization Unit' }, + { jiraId: 2474, name: 'Confluence', fieldName: 'confluence', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 2400, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 2401, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true } + ], + }, + 'Supplier': { + jiraTypeId: 37, + name: 'Supplier', + typeName: 'Supplier', + syncPriority: 2, + objectCount: 471, + attributes: [ + { jiraId: 337, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 338, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 339, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 340, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 1143, name: 'Address', fieldName: 'address', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4635, name: 'Street', fieldName: 'street', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4636, name: 'House number', fieldName: 'houseNumber', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4637, name: 'City', fieldName: 'city', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4638, name: 'Postal code', fieldName: 'postalCode', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4639, name: 'Country code', fieldName: 'countryCode', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 1144, name: 'Company Telephone', fieldName: 'companyTelephone', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 1145, name: 'Company Website', fieldName: 'companyWebsite', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 1146, name: 'Company email', fieldName: 'companyEmail', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 1147, name: 'Servicedesk Telephone', fieldName: 'servicedeskTelephone', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 1148, name: 'Servicedesk email', fieldName: 'servicedeskEmail', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 1149, name: 'Consultant 1', fieldName: 'consultant1', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 2412, name: 'Consultant 1 e-mail', fieldName: 'consultant1EMail', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 1150, name: 'Consultant 2', fieldName: 'consultant2', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 2413, name: 'Consultant 2 e-mail', fieldName: 'consultant2EMail', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 1151, name: 'Consultant 3', fieldName: 'consultant3', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 2414, name: 'Consultant 3 e-mail', fieldName: 'consultant3EMail', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'Software': { + jiraTypeId: 421, + name: 'Software', + typeName: 'Software', + syncPriority: 2, + objectCount: 307, + attributes: [ + { jiraId: 4806, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4807, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4808, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4809, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4836, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4810, name: 'ApplicationComponent', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' }, + { jiraId: 4811, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4812, name: 'SupplierProduct', fieldName: 'supplierProduct', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' }, + { jiraId: 4813, name: 'SupplierTechnical', fieldName: 'supplierTechnical', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' } + ], + }, + 'SoftwarePatch': { + jiraTypeId: 423, + name: 'SoftwarePatch', + typeName: 'SoftwarePatch', + syncPriority: 2, + objectCount: 555, + attributes: [ + { jiraId: 4816, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4817, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'naamgeving: software-versie' }, + { jiraId: 4820, name: 'Software', fieldName: 'software', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 421, referenceTypeName: 'Software' }, + { jiraId: 4821, name: 'Version', fieldName: 'version', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4835, name: 'Status', fieldName: 'status', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4818, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4819, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4839, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'Manual': { + jiraTypeId: 430, + name: 'Manual', + typeName: 'Manual', + syncPriority: 5, + objectCount: 23, + attributes: [ + { jiraId: 4865, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4866, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4869, name: 'Link', fieldName: 'link', type: 'float', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, description: 'Confluence manual page' }, + { jiraId: 4870, name: 'Type', fieldName: 'type', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4867, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4868, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4871, name: 'Target', fieldName: 'target', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 430, referenceTypeName: 'Manual', description: 'If applicable' } + ], + }, + 'Measures': { + jiraTypeId: 391, + name: 'Measures', + typeName: 'Measures', + syncPriority: 5, + objectCount: 11, + attributes: [ + { jiraId: 4512, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4513, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4516, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4522, name: 'Obliged', fieldName: 'obliged', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4521, name: 'Availability', fieldName: 'availability', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4523, name: 'Optional', fieldName: 'optional', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4514, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4515, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true } + ], + }, + 'Rebootgroups': { + jiraTypeId: 251, + name: 'Rebootgroups', + typeName: 'Rebootgroups', + syncPriority: 5, + objectCount: 14, + attributes: [ + { jiraId: 2391, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 2392, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 2397, name: 'Reboot day', fieldName: 'rebootDay', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 2395, name: 'Reboot time', fieldName: 'rebootTime', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 2396, name: 'Install information', fieldName: 'installInformation', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 2393, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 2394, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true } + ], + }, + 'ApplicationManagementTeam': { + jiraTypeId: 440, + name: 'Application Management - Team', + typeName: 'ApplicationManagementTeam', + syncPriority: 5, + objectCount: 11, + attributes: [ + { jiraId: 4946, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4947, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4948, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4949, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4950, name: 'Type', fieldName: 'type', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'ApplicationManagementSubteam': { + jiraTypeId: 435, + name: 'Application Management - Subteam', + typeName: 'ApplicationManagementSubteam', + syncPriority: 5, + objectCount: 19, + attributes: [ + { jiraId: 4907, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4951, name: 'Application Management - Team', fieldName: 'applicationManagementTeam', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 440, referenceTypeName: 'ApplicationManagementTeam' }, + { jiraId: 4908, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4909, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4910, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4912, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'Assets': { + jiraTypeId: 42, + name: 'Assets', + typeName: 'Assets', + syncPriority: 8, + objectCount: 0, + attributes: [ + { jiraId: 384, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 385, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 386, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 387, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true } + ], + }, + 'CI': { + jiraTypeId: 392, + name: 'CI', + typeName: 'CI', + syncPriority: 8, + objectCount: 0, + attributes: [ + { jiraId: 4552, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4553, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4554, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4555, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true } + ], + }, + 'Metadata': { + jiraTypeId: 57, + name: 'Metadata', + typeName: 'Metadata', + syncPriority: 8, + objectCount: 0, + attributes: [ + { jiraId: 499, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 500, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4705, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 501, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 502, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true } + ], + }, + 'ApplicationManagementHosting': { + jiraTypeId: 438, + name: 'Application Management - Hosting', + typeName: 'ApplicationManagementHosting', + syncPriority: 8, + objectCount: 4, + attributes: [ + { jiraId: 4933, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4934, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4935, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4936, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4938, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'ApplicationManagementTam': { + jiraTypeId: 439, + name: 'Application Management - TAM', + typeName: 'ApplicationManagementTam', + syncPriority: 8, + objectCount: 4, + attributes: [ + { jiraId: 4940, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4941, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4942, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4943, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4944, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'ApplicationManagementNumberOfUsers': { + jiraTypeId: 433, + name: 'Application Management - Number of Users', + typeName: 'ApplicationManagementNumberOfUsers', + syncPriority: 8, + objectCount: 7, + attributes: [ + { jiraId: 4889, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4890, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4891, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4892, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4893, name: 'Examples', fieldName: 'examples', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4894, name: 'Factor', fieldName: 'factor', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4895, name: 'Order', fieldName: 'order', type: 'integer', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'CertificateType': { + jiraTypeId: 407, + name: 'Certificate Type', + typeName: 'CertificateType', + syncPriority: 10, + objectCount: 3, + attributes: [ + { jiraId: 4687, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4688, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4689, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4690, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true } + ], + }, + 'ApplicationFunctionCategory': { + jiraTypeId: 437, + name: 'ApplicationFunctionCategory', + typeName: 'ApplicationFunctionCategory', + syncPriority: 10, + objectCount: 13, + attributes: [ + { jiraId: 4921, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4922, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4923, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4924, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4925, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'ApplicationFunction': { + jiraTypeId: 403, + name: 'ApplicationFunction', + typeName: 'ApplicationFunction', + syncPriority: 10, + objectCount: 93, + attributes: [ + { jiraId: 4650, name: 'AppFuncGUID', fieldName: 'appFuncGUID', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4646, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4926, name: 'Application Function Category', fieldName: 'applicationFunctionCategory', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 437, referenceTypeName: 'ApplicationFunctionCategory' }, + { jiraId: 4647, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4651, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4920, name: 'Keywords', fieldName: 'keywords', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4648, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4649, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4919, name: 'Application Management - Application Cluster', fieldName: 'applicationManagementApplicationCluster', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 435, referenceTypeName: 'ApplicationManagementSubteam' } + ], + }, + 'CertificateClassificationType': { + jiraTypeId: 408, + name: 'Certificate ClassificationType', + typeName: 'CertificateClassificationType', + syncPriority: 10, + objectCount: 8, + attributes: [ + { jiraId: 4691, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4692, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4703, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'https://jira.zuyderland.nl/browse/CMDB-430' }, + { jiraId: 4693, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4694, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true } + ], + }, + 'BusinessImpactAnalyse': { + jiraTypeId: 41, + name: 'Business Impact Analyse', + typeName: 'BusinessImpactAnalyse', + syncPriority: 10, + objectCount: 6, + attributes: [ + { jiraId: 369, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 370, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 1107, name: 'Service Window', fieldName: 'serviceWindow', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 1108, name: 'Availabilty', fieldName: 'availabilty', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 2411, name: 'BIA Check', fieldName: 'biaCheck', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 1109, name: 'Recovery Time Objective (RTO)', fieldName: 'recoveryTimeObjectiveRTO', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 1110, name: 'Recovery Point Objective (RPO)', fieldName: 'recoveryPointObjectiveRPO', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 1111, name: 'Environments', fieldName: 'environments', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4930, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4931, name: 'Indicators', fieldName: 'indicators', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 371, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 372, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true } + ], + }, + 'BusinessImportance': { + jiraTypeId: 44, + name: 'Business Importance', + typeName: 'BusinessImportance', + syncPriority: 10, + objectCount: 8, + attributes: [ + { jiraId: 395, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 396, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4517, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 397, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 398, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true } + ], + }, + 'HostingType': { + jiraTypeId: 39, + name: 'Hosting Type', + typeName: 'HostingType', + syncPriority: 10, + objectCount: 6, + attributes: [ + { jiraId: 345, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 346, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4520, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 347, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 348, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true } + ], + }, + 'Organisation': { + jiraTypeId: 390, + name: 'Organisation', + typeName: 'Organisation', + syncPriority: 10, + objectCount: 6, + attributes: [ + { jiraId: 4507, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4508, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4511, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4509, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4510, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true } + ], + }, + 'ApplicationManagementApplicationType': { + jiraTypeId: 436, + name: 'Application Management - Application Type', + typeName: 'ApplicationManagementApplicationType', + syncPriority: 10, + objectCount: 4, + attributes: [ + { jiraId: 4913, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4914, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4915, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4916, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4917, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'ApplicationManagementDynamicsFactor': { + jiraTypeId: 434, + name: 'Application Management - Dynamics Factor', + typeName: 'ApplicationManagementDynamicsFactor', + syncPriority: 10, + objectCount: 4, + attributes: [ + { jiraId: 4896, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4897, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4898, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4899, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4900, name: 'Summary', fieldName: 'summary', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4928, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4901, name: 'Factor', fieldName: 'factor', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'ApplicationManagementComplexityFactor': { + jiraTypeId: 432, + name: 'Application Management - Complexity Factor', + typeName: 'ApplicationManagementComplexityFactor', + syncPriority: 10, + objectCount: 4, + attributes: [ + { jiraId: 4883, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4884, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4885, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4886, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4887, name: 'Summary', fieldName: 'summary', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4929, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4888, name: 'Factor', fieldName: 'factor', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'IctGovernanceModel': { + jiraTypeId: 431, + name: 'ICT Governance Model', + typeName: 'IctGovernanceModel', + syncPriority: 10, + objectCount: 6, + attributes: [ + { jiraId: 4874, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4875, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { jiraId: 4876, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4877, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4878, name: 'Summary', fieldName: 'summary', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4879, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4880, name: 'Remarks', fieldName: 'remarks', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4881, name: 'Application', fieldName: 'application', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + } +}; + +// ============================================================================= +// Lookup Maps +// ============================================================================= + +/** Map from Jira Type ID to TypeScript type name */ +export const TYPE_ID_TO_NAME: Record = { + 38: 'ApplicationComponent', + 59: 'Flows', + 48: 'Server', + 418: 'AzureSubscription', + 424: 'PackageBuild', + 422: 'Package', + 406: 'Certificate', + 401: 'PrivilegedUser', + 252: 'Domain', + 37: 'Supplier', + 421: 'Software', + 423: 'SoftwarePatch', + 430: 'Manual', + 391: 'Measures', + 251: 'Rebootgroups', + 440: 'ApplicationManagementTeam', + 435: 'ApplicationManagementSubteam', + 42: 'Assets', + 392: 'CI', + 57: 'Metadata', + 438: 'ApplicationManagementHosting', + 439: 'ApplicationManagementTam', + 433: 'ApplicationManagementNumberOfUsers', + 407: 'CertificateType', + 437: 'ApplicationFunctionCategory', + 403: 'ApplicationFunction', + 408: 'CertificateClassificationType', + 41: 'BusinessImpactAnalyse', + 44: 'BusinessImportance', + 39: 'HostingType', + 390: 'Organisation', + 436: 'ApplicationManagementApplicationType', + 434: 'ApplicationManagementDynamicsFactor', + 432: 'ApplicationManagementComplexityFactor', + 431: 'IctGovernanceModel', +}; + +/** Map from TypeScript type name to Jira Type ID */ +export const TYPE_NAME_TO_ID: Record = { + 'ApplicationComponent': 38, + 'Flows': 59, + 'Server': 48, + 'AzureSubscription': 418, + 'PackageBuild': 424, + 'Package': 422, + 'Certificate': 406, + 'PrivilegedUser': 401, + 'Domain': 252, + 'Supplier': 37, + 'Software': 421, + 'SoftwarePatch': 423, + 'Manual': 430, + 'Measures': 391, + 'Rebootgroups': 251, + 'ApplicationManagementTeam': 440, + 'ApplicationManagementSubteam': 435, + 'Assets': 42, + 'CI': 392, + 'Metadata': 57, + 'ApplicationManagementHosting': 438, + 'ApplicationManagementTam': 439, + 'ApplicationManagementNumberOfUsers': 433, + 'CertificateType': 407, + 'ApplicationFunctionCategory': 437, + 'ApplicationFunction': 403, + 'CertificateClassificationType': 408, + 'BusinessImpactAnalyse': 41, + 'BusinessImportance': 44, + 'HostingType': 39, + 'Organisation': 390, + 'ApplicationManagementApplicationType': 436, + 'ApplicationManagementDynamicsFactor': 434, + 'ApplicationManagementComplexityFactor': 432, + 'IctGovernanceModel': 431, +}; + +/** Map from Jira object type name to TypeScript type name */ +export const JIRA_NAME_TO_TYPE: Record = { + 'Application Component': 'ApplicationComponent', + 'Flows': 'Flows', + 'Server': 'Server', + 'AzureSubscription': 'AzureSubscription', + 'PackageBuild': 'PackageBuild', + 'Package': 'Package', + 'Certificate': 'Certificate', + 'Privileged User': 'PrivilegedUser', + 'Domain': 'Domain', + 'Supplier': 'Supplier', + 'Software': 'Software', + 'SoftwarePatch': 'SoftwarePatch', + 'Manual': 'Manual', + 'Measures': 'Measures', + 'Rebootgroups': 'Rebootgroups', + 'Application Management - Team': 'ApplicationManagementTeam', + 'Application Management - Subteam': 'ApplicationManagementSubteam', + 'Assets': 'Assets', + 'CI': 'CI', + 'Metadata': 'Metadata', + 'Application Management - Hosting': 'ApplicationManagementHosting', + 'Application Management - TAM': 'ApplicationManagementTam', + 'Application Management - Number of Users': 'ApplicationManagementNumberOfUsers', + 'Certificate Type': 'CertificateType', + 'ApplicationFunctionCategory': 'ApplicationFunctionCategory', + 'ApplicationFunction': 'ApplicationFunction', + 'Certificate ClassificationType': 'CertificateClassificationType', + 'Business Impact Analyse': 'BusinessImpactAnalyse', + 'Business Importance': 'BusinessImportance', + 'Hosting Type': 'HostingType', + 'Organisation': 'Organisation', + 'Application Management - Application Type': 'ApplicationManagementApplicationType', + 'Application Management - Dynamics Factor': 'ApplicationManagementDynamicsFactor', + 'Application Management - Complexity Factor': 'ApplicationManagementComplexityFactor', + 'ICT Governance Model': 'IctGovernanceModel', +}; + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** Get attribute definition by type and field name */ +export function getAttributeDefinition(typeName: string, fieldName: string): AttributeDefinition | undefined { + const objectType = OBJECT_TYPES[typeName]; + if (!objectType) return undefined; + return objectType.attributes.find(a => a.fieldName === fieldName); +} + +/** Get attribute definition by type and Jira attribute ID */ +export function getAttributeById(typeName: string, jiraId: number): AttributeDefinition | undefined { + const objectType = OBJECT_TYPES[typeName]; + if (!objectType) return undefined; + return objectType.attributes.find(a => a.jiraId === jiraId); +} + +/** Get attribute definition by type and Jira attribute name */ +export function getAttributeByName(typeName: string, attrName: string): AttributeDefinition | undefined { + const objectType = OBJECT_TYPES[typeName]; + if (!objectType) return undefined; + return objectType.attributes.find(a => a.name === attrName); +} + +/** Get attribute Jira ID by type and attribute name - throws if not found */ +export function getAttributeId(typeName: string, attrName: string): number { + const attr = getAttributeByName(typeName, attrName); + if (!attr) { + throw new Error(`Attribute "${attrName}" not found on type "${typeName}"`); + } + return attr.jiraId; +} + +/** Get all reference attributes for a type */ +export function getReferenceAttributes(typeName: string): AttributeDefinition[] { + const objectType = OBJECT_TYPES[typeName]; + if (!objectType) return []; + return objectType.attributes.filter(a => a.type === 'reference'); +} + +/** Get all object types sorted by sync priority */ +export function getObjectTypesBySyncPriority(): ObjectTypeDefinition[] { + return Object.values(OBJECT_TYPES).sort((a, b) => a.syncPriority - b.syncPriority); +} diff --git a/backend/src/generated/jira-types.ts b/backend/src/generated/jira-types.ts new file mode 100644 index 0000000..06ffcc5 --- /dev/null +++ b/backend/src/generated/jira-types.ts @@ -0,0 +1,933 @@ +// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY +// Generated from Jira Assets Schema via REST API +// Generated at: 2026-01-09T02:12:50.973Z +// +// Re-generate with: npm run generate-schema + +// ============================================================================= +// Base Types +// ============================================================================= + +/** Reference to another CMDB object */ +export interface ObjectReference { + objectId: string; + objectKey: string; + label: string; + // Optional enriched data from referenced object + factor?: number; +} + +/** Base interface for all CMDB objects */ +export interface BaseCMDBObject { + id: string; + objectKey: string; + label: string; + _objectType: string; + _jiraUpdatedAt: string; + _jiraCreatedAt: string; +} + +// ============================================================================= +// Object Type Interfaces +// ============================================================================= + +/** Application Component (Jira Type ID: 38, 596 objects) */ +export interface ApplicationComponent extends BaseCMDBObject { + _objectType: 'ApplicationComponent'; + + // Scalar attributes + reference: string | null; // Niet aanpassen. GUID - Enterprise Architect + key: string | null; + searchReference: string | null; // Additionele zoekwoorden t.b.v. search + name: string | null; // Unieke naam object + created: string | null; + updated: string | null; + description: string | null; // * Application description + status: string | null; // Application Lifecycle Management + confluenceSpace: number | null; + zenyaID: number | null; + zenyaURL: string | null; + customDevelopment: boolean | null; // Is er sprake van eigen programmatuur? + piiData: boolean | null; // Maakt applicatie gebruik van Persoonlijk identificeerbare informatie? + medicalData: boolean | null; // Maakt de Application Component gebruik van medische data? + functionalApplicationManagement: string | null; + technicalApplicationManagementPrimary: boolean[]; + technicalApplicationManagementSecondary: boolean[]; + medischeTechniek: boolean | null; // Is er een link met medische techniek + technischeArchitectuurTA: string | null; // Komt uit Enterprise Architect mee + measures: string | null; + generateConfluenceSpace: boolean | null; // Wordt gebruikt door script om space te genereren - niet verwijderen. Attribuut is hidden + importDate: string | null; + applicationManagementOverrideFTE: number | null; + + // Reference attributes + organisation: ObjectReference | null; // -> Organisation + applicationFunction: ObjectReference[]; // -> ApplicationFunction + businessImportance: ObjectReference | null; // -> BusinessImportance + businessImpactAnalyse: ObjectReference | null; // -> BusinessImpactAnalyse + applicationComponentHostingType: ObjectReference | null; // -> HostingType + platform: ObjectReference | null; // -> ApplicationComponent + referencedApplicationComponent: ObjectReference[]; // -> ApplicationComponent + authenticationMethod: ObjectReference | null; // -> ApplicationComponent + monitoring: ObjectReference | null; // -> ApplicationComponent + supplierProduct: ObjectReference | null; // -> Supplier + supplierTechnical: ObjectReference | null; // -> Supplier + supplierImplementation: ObjectReference | null; // -> Supplier + supplierConsultancy: ObjectReference | null; // -> Supplier + businessOwner: ObjectReference | null; // -> Organization Unit + systemOwner: ObjectReference | null; // -> User + technicalApplicationManagement: ObjectReference | null; // -> group + sourceStatus: ObjectReference | null; + ictGovernanceModel: ObjectReference | null; // -> IctGovernanceModel + applicationManagementApplicationType: ObjectReference | null; // -> ApplicationManagementApplicationType + applicationManagementHosting: ObjectReference | null; // -> ApplicationManagementHosting + applicationManagementTAM: ObjectReference | null; // -> ApplicationManagementTam + applicationManagementDynamicsFactor: ObjectReference | null; // -> ApplicationManagementDynamicsFactor + applicationManagementComplexityFactor: ObjectReference | null; // -> ApplicationManagementComplexityFactor + applicationManagementNumberOfUsers: ObjectReference | null; // -> ApplicationManagementNumberOfUsers + applicationManagementSubteam: ObjectReference | null; // -> ApplicationManagementSubteam + +} + +/** Flows (Jira Type ID: 59, 903 objects) */ +export interface Flows extends BaseCMDBObject { + _objectType: 'Flows'; + + // Scalar attributes + reference: string | null; + presentInImportEnterpriseArchitect: boolean | null; + key: string | null; + name: string | null; // The name of the object + searchReferenceSource: string | null; + searchReferenceTarget: string | null; + type: string | null; + protocol: string | null; + details: string | null; + broker: string | null; + status: string | null; + created: string | null; + updated: string | null; + importDate: string | null; + + // Reference attributes + source: ObjectReference | null; // -> ApplicationComponent + target: ObjectReference | null; // -> ApplicationComponent + +} + +/** Server (Jira Type ID: 48, 909 objects) */ +export interface Server extends BaseCMDBObject { + _objectType: 'Server'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + description: string | null; + status: string | null; + vMLocation: string | null; + vMOSType: string | null; + memoryMB: number | null; + cPUCores: number | null; + nICCount: number | null; + dataDisks: number | null; + privateIPAddress: string | null; + cluster: string | null; + vMSize: string | null; + created: string | null; + updated: string | null; + dBA: boolean | null; + importDate: string | null; + state: string | null; + + // Reference attributes + applicationComponent: ObjectReference | null; // -> ApplicationComponent + azureSubscription: ObjectReference[]; // -> AzureSubscription + rebootGroup: ObjectReference | null; // -> Rebootgroups + sourceStatus: ObjectReference | null; + +} + +/** AzureSubscription (Jira Type ID: 418, 151 objects) */ +export interface AzureSubscription extends BaseCMDBObject { + _objectType: 'AzureSubscription'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the subscription + status: string | null; + created: string | null; + updated: string | null; + importDate: string | null; + + // Reference attributes + server: ObjectReference[]; // -> Server + applicationComponent: ObjectReference | null; // -> ApplicationComponent + +} + +/** PackageBuild (Jira Type ID: 424, 496 objects) */ +export interface PackageBuild extends BaseCMDBObject { + _objectType: 'PackageBuild'; + + // Scalar attributes + key: string | null; + name: string | null; // naamgeving: software-versie + version: string | null; + created: string | null; + updated: string | null; + description: string | null; + buildNumber: string | null; + architecture: string | null; + language: string | null; + appDelivery: string | null; + aDlocalMemberships: string | null; + aDmemberships: string | null; + entraIDmemberships: string | null; + platform: string | null; + inGoldenImage: boolean | null; + importDate: string | null; + + // Reference attributes + software: ObjectReference | null; // -> Software + status: ObjectReference | null; + environment: ObjectReference[]; + +} + +/** Package (Jira Type ID: 422, 299 objects) */ +export interface Package extends BaseCMDBObject { + _objectType: 'Package'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + description: string | null; + status: string | null; + businessImportance: string | null; + authentication: string | null; + maintenanceContract: boolean | null; + importDate: string | null; + + // Reference attributes + applicationComponent: ObjectReference | null; // -> ApplicationComponent + supplierProduct: ObjectReference | null; // -> Supplier + supplierTechnical: ObjectReference | null; // -> Supplier + +} + +/** Certificate (Jira Type ID: 406, 508 objects) */ +export interface Certificate extends BaseCMDBObject { + _objectType: 'Certificate'; + + // Scalar attributes + key: string | null; + name: string | null; // Naam van het certificaat + status: string | null; // Status is Active, Closed, Unknown (date 1970-01-01) or Support Requested (ticket aangemaakt vanwege Expiry Date) + requester: boolean | null; // De aanvrager (medewerker) van het betreffende certificaat + autorenew: boolean | null; // Indien een expiry date beschikbaar, dan op False + expiryDate: string | null; // Als er geen Expiry Date bekend is, wordt de waarde ingesteld op 01-01-1970 + reminderMailbox: boolean[]; + description: string | null; // Algemene omschrijving + created: string | null; + updated: string | null; + + // Reference attributes + type: ObjectReference | null; // -> CertificateType + classificationType: ObjectReference | null; // -> CertificateClassificationType + issuingAuthority: ObjectReference | null; // -> Supplier + issuingSupplier: ObjectReference | null; // -> Supplier + reminderInDays: ObjectReference | null; + certificateOwner: ObjectReference | null; // -> Organization Unit + itOperationsTeam: ObjectReference | null; // -> group + applicationManagement: ObjectReference | null; // -> group + applicationComponent: ObjectReference[]; // -> ApplicationComponent + domain: ObjectReference[]; // -> Domain + server: ObjectReference[]; // -> Server + extraInstallatie: ObjectReference[]; // -> ApplicationComponent + +} + +/** Privileged User (Jira Type ID: 401, 728 objects) */ +export interface PrivilegedUser extends BaseCMDBObject { + _objectType: 'PrivilegedUser'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + uPN: string | null; // User Principal Name + description: string | null; // Description + topdeskTicket: string | null; + eMail: string | null; + mobile: string | null; + methodsRegistered: string | null; + creationDate: string | null; + expiryDate: string | null; + lastUpdated: string | null; + passwordLastSet: string | null; + passwordNeverExpires: boolean | null; + mfaRegistered: boolean | null; + selfServicePasswordResetUsed: boolean | null; + selfServicePasswordResetEnabled: boolean | null; + lastLogon: string | null; + created: string | null; + updated: string | null; + sID: string | null; + importDate: string | null; + + // Reference attributes + applicationComponent: ObjectReference | null; // -> ApplicationComponent + +} + +/** Domain (Jira Type ID: 252, 796 objects) */ +export interface Domain extends BaseCMDBObject { + _objectType: 'Domain'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + status: string | null; + description: string | null; + registrationDate: string | null; + redirectURL: string | null; + mailEnabled: boolean | null; + confluence: number | null; + created: string | null; + updated: string | null; + + // Reference attributes + domainType: ObjectReference | null; + domainFunction: ObjectReference | null; + dnsManagement: ObjectReference | null; // -> Supplier + applicationComponent: ObjectReference[]; // -> ApplicationComponent + applicationComponentMonitoring: ObjectReference[]; // -> ApplicationComponent + websiteAvailability: ObjectReference | null; + businessOwner: ObjectReference | null; // -> Organization Unit + +} + +/** Supplier (Jira Type ID: 37, 471 objects) */ +export interface Supplier extends BaseCMDBObject { + _objectType: 'Supplier'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + address: string | null; + street: string | null; + houseNumber: string | null; + city: string | null; + postalCode: string | null; + countryCode: string | null; + companyTelephone: string | null; + companyWebsite: string | null; + companyEmail: string | null; + servicedeskTelephone: string | null; + servicedeskEmail: string | null; + consultant1: string | null; + consultant1EMail: string | null; + consultant2: string | null; + consultant2EMail: string | null; + consultant3: string | null; + consultant3EMail: string | null; + +} + +/** Software (Jira Type ID: 421, 307 objects) */ +export interface Software extends BaseCMDBObject { + _objectType: 'Software'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + description: string | null; + status: string | null; + + // Reference attributes + applicationComponent: ObjectReference | null; // -> ApplicationComponent + supplierProduct: ObjectReference | null; // -> Supplier + supplierTechnical: ObjectReference | null; // -> Supplier + +} + +/** SoftwarePatch (Jira Type ID: 423, 555 objects) */ +export interface SoftwarePatch extends BaseCMDBObject { + _objectType: 'SoftwarePatch'; + + // Scalar attributes + key: string | null; + name: string | null; // naamgeving: software-versie + version: string | null; + created: string | null; + updated: string | null; + description: string | null; + + // Reference attributes + software: ObjectReference | null; // -> Software + status: ObjectReference | null; + +} + +/** Manual (Jira Type ID: 430, 23 objects) */ +export interface Manual extends BaseCMDBObject { + _objectType: 'Manual'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + link: number[]; // Confluence manual page + created: string | null; + updated: string | null; + + // Reference attributes + type: ObjectReference | null; + target: ObjectReference[]; // -> Manual + +} + +/** Measures (Jira Type ID: 391, 11 objects) */ +export interface Measures extends BaseCMDBObject { + _objectType: 'Measures'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + description: string | null; + obliged: string | null; + created: string | null; + updated: string | null; + + // Reference attributes + availability: ObjectReference | null; + optional: ObjectReference | null; + +} + +/** Rebootgroups (Jira Type ID: 251, 14 objects) */ +export interface Rebootgroups extends BaseCMDBObject { + _objectType: 'Rebootgroups'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + rebootDay: string | null; + rebootTime: string | null; + installInformation: string | null; + created: string | null; + updated: string | null; + +} + +/** Application Management - Team (Jira Type ID: 440, 11 objects) */ +export interface ApplicationManagementTeam extends BaseCMDBObject { + _objectType: 'ApplicationManagementTeam'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + + // Reference attributes + type: ObjectReference | null; + +} + +/** Application Management - Subteam (Jira Type ID: 435, 19 objects) */ +export interface ApplicationManagementSubteam extends BaseCMDBObject { + _objectType: 'ApplicationManagementSubteam'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + description: string | null; + + // Reference attributes + applicationManagementTeam: ObjectReference | null; // -> ApplicationManagementTeam + +} + +/** Assets (Jira Type ID: 42, 0 objects) */ +export interface Assets extends BaseCMDBObject { + _objectType: 'Assets'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + +} + +/** CI (Jira Type ID: 392, 0 objects) */ +export interface CI extends BaseCMDBObject { + _objectType: 'CI'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + +} + +/** Metadata (Jira Type ID: 57, 0 objects) */ +export interface Metadata extends BaseCMDBObject { + _objectType: 'Metadata'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + description: string | null; + created: string | null; + updated: string | null; + +} + +/** Application Management - Hosting (Jira Type ID: 438, 4 objects) */ +export interface ApplicationManagementHosting extends BaseCMDBObject { + _objectType: 'ApplicationManagementHosting'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + description: string | null; + +} + +/** Application Management - TAM (Jira Type ID: 439, 4 objects) */ +export interface ApplicationManagementTam extends BaseCMDBObject { + _objectType: 'ApplicationManagementTam'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + description: string | null; + +} + +/** Application Management - Number of Users (Jira Type ID: 433, 7 objects) */ +export interface ApplicationManagementNumberOfUsers extends BaseCMDBObject { + _objectType: 'ApplicationManagementNumberOfUsers'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + examples: string | null; + factor: number | null; + order: number | null; + +} + +/** Certificate Type (Jira Type ID: 407, 3 objects) */ +export interface CertificateType extends BaseCMDBObject { + _objectType: 'CertificateType'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + +} + +/** ApplicationFunctionCategory (Jira Type ID: 437, 13 objects) */ +export interface ApplicationFunctionCategory extends BaseCMDBObject { + _objectType: 'ApplicationFunctionCategory'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + description: string | null; + +} + +/** ApplicationFunction (Jira Type ID: 403, 93 objects) */ +export interface ApplicationFunction extends BaseCMDBObject { + _objectType: 'ApplicationFunction'; + + // Scalar attributes + appFuncGUID: string | null; + key: string | null; + name: string | null; // The name of the object + description: string | null; + keywords: string | null; + created: string | null; + updated: string | null; + + // Reference attributes + applicationFunctionCategory: ObjectReference | null; // -> ApplicationFunctionCategory + applicationManagementApplicationCluster: ObjectReference | null; // -> ApplicationManagementSubteam + +} + +/** Certificate ClassificationType (Jira Type ID: 408, 8 objects) */ +export interface CertificateClassificationType extends BaseCMDBObject { + _objectType: 'CertificateClassificationType'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + description: string | null; // https://jira.zuyderland.nl/browse/CMDB-430 + created: string | null; + updated: string | null; + +} + +/** Business Impact Analyse (Jira Type ID: 41, 6 objects) */ +export interface BusinessImpactAnalyse extends BaseCMDBObject { + _objectType: 'BusinessImpactAnalyse'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + serviceWindow: string | null; + availabilty: string | null; + biaCheck: string | null; + recoveryTimeObjectiveRTO: string | null; + recoveryPointObjectiveRPO: string | null; + environments: string | null; + description: string | null; + indicators: string | null; + created: string | null; + updated: string | null; + +} + +/** Business Importance (Jira Type ID: 44, 8 objects) */ +export interface BusinessImportance extends BaseCMDBObject { + _objectType: 'BusinessImportance'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + description: string | null; + created: string | null; + updated: string | null; + +} + +/** Hosting Type (Jira Type ID: 39, 6 objects) */ +export interface HostingType extends BaseCMDBObject { + _objectType: 'HostingType'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + description: string | null; + created: string | null; + updated: string | null; + +} + +/** Organisation (Jira Type ID: 390, 6 objects) */ +export interface Organisation extends BaseCMDBObject { + _objectType: 'Organisation'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + description: string | null; + created: string | null; + updated: string | null; + +} + +/** Application Management - Application Type (Jira Type ID: 436, 4 objects) */ +export interface ApplicationManagementApplicationType extends BaseCMDBObject { + _objectType: 'ApplicationManagementApplicationType'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + description: string | null; + +} + +/** Application Management - Dynamics Factor (Jira Type ID: 434, 4 objects) */ +export interface ApplicationManagementDynamicsFactor extends BaseCMDBObject { + _objectType: 'ApplicationManagementDynamicsFactor'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + summary: string | null; + description: string | null; + factor: number | null; + +} + +/** Application Management - Complexity Factor (Jira Type ID: 432, 4 objects) */ +export interface ApplicationManagementComplexityFactor extends BaseCMDBObject { + _objectType: 'ApplicationManagementComplexityFactor'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + summary: string | null; + description: string | null; + factor: number | null; + +} + +/** ICT Governance Model (Jira Type ID: 431, 6 objects) */ +export interface IctGovernanceModel extends BaseCMDBObject { + _objectType: 'IctGovernanceModel'; + + // Scalar attributes + key: string | null; + name: string | null; // The name of the object + created: string | null; + updated: string | null; + summary: string | null; + description: string | null; + remarks: string | null; + application: string | null; + +} + +// ============================================================================= +// Union Types +// ============================================================================= + +/** Union of all CMDB object types */ +export type CMDBObject = + | ApplicationComponent + | Flows + | Server + | AzureSubscription + | PackageBuild + | Package + | Certificate + | PrivilegedUser + | Domain + | Supplier + | Software + | SoftwarePatch + | Manual + | Measures + | Rebootgroups + | ApplicationManagementTeam + | ApplicationManagementSubteam + | Assets + | CI + | Metadata + | ApplicationManagementHosting + | ApplicationManagementTam + | ApplicationManagementNumberOfUsers + | CertificateType + | ApplicationFunctionCategory + | ApplicationFunction + | CertificateClassificationType + | BusinessImpactAnalyse + | BusinessImportance + | HostingType + | Organisation + | ApplicationManagementApplicationType + | ApplicationManagementDynamicsFactor + | ApplicationManagementComplexityFactor + | IctGovernanceModel; + +/** All valid object type names */ +export type CMDBObjectTypeName = + | 'ApplicationComponent' + | 'Flows' + | 'Server' + | 'AzureSubscription' + | 'PackageBuild' + | 'Package' + | 'Certificate' + | 'PrivilegedUser' + | 'Domain' + | 'Supplier' + | 'Software' + | 'SoftwarePatch' + | 'Manual' + | 'Measures' + | 'Rebootgroups' + | 'ApplicationManagementTeam' + | 'ApplicationManagementSubteam' + | 'Assets' + | 'CI' + | 'Metadata' + | 'ApplicationManagementHosting' + | 'ApplicationManagementTam' + | 'ApplicationManagementNumberOfUsers' + | 'CertificateType' + | 'ApplicationFunctionCategory' + | 'ApplicationFunction' + | 'CertificateClassificationType' + | 'BusinessImpactAnalyse' + | 'BusinessImportance' + | 'HostingType' + | 'Organisation' + | 'ApplicationManagementApplicationType' + | 'ApplicationManagementDynamicsFactor' + | 'ApplicationManagementComplexityFactor' + | 'IctGovernanceModel'; + +// ============================================================================= +// Type Guards +// ============================================================================= + +export function isApplicationComponent(obj: CMDBObject): obj is ApplicationComponent { + return obj._objectType === 'ApplicationComponent'; +} + +export function isFlows(obj: CMDBObject): obj is Flows { + return obj._objectType === 'Flows'; +} + +export function isServer(obj: CMDBObject): obj is Server { + return obj._objectType === 'Server'; +} + +export function isAzureSubscription(obj: CMDBObject): obj is AzureSubscription { + return obj._objectType === 'AzureSubscription'; +} + +export function isPackageBuild(obj: CMDBObject): obj is PackageBuild { + return obj._objectType === 'PackageBuild'; +} + +export function isPackage(obj: CMDBObject): obj is Package { + return obj._objectType === 'Package'; +} + +export function isCertificate(obj: CMDBObject): obj is Certificate { + return obj._objectType === 'Certificate'; +} + +export function isPrivilegedUser(obj: CMDBObject): obj is PrivilegedUser { + return obj._objectType === 'PrivilegedUser'; +} + +export function isDomain(obj: CMDBObject): obj is Domain { + return obj._objectType === 'Domain'; +} + +export function isSupplier(obj: CMDBObject): obj is Supplier { + return obj._objectType === 'Supplier'; +} + +export function isSoftware(obj: CMDBObject): obj is Software { + return obj._objectType === 'Software'; +} + +export function isSoftwarePatch(obj: CMDBObject): obj is SoftwarePatch { + return obj._objectType === 'SoftwarePatch'; +} + +export function isManual(obj: CMDBObject): obj is Manual { + return obj._objectType === 'Manual'; +} + +export function isMeasures(obj: CMDBObject): obj is Measures { + return obj._objectType === 'Measures'; +} + +export function isRebootgroups(obj: CMDBObject): obj is Rebootgroups { + return obj._objectType === 'Rebootgroups'; +} + +export function isApplicationManagementTeam(obj: CMDBObject): obj is ApplicationManagementTeam { + return obj._objectType === 'ApplicationManagementTeam'; +} + +export function isApplicationManagementSubteam(obj: CMDBObject): obj is ApplicationManagementSubteam { + return obj._objectType === 'ApplicationManagementSubteam'; +} + +export function isAssets(obj: CMDBObject): obj is Assets { + return obj._objectType === 'Assets'; +} + +export function isCI(obj: CMDBObject): obj is CI { + return obj._objectType === 'CI'; +} + +export function isMetadata(obj: CMDBObject): obj is Metadata { + return obj._objectType === 'Metadata'; +} + +export function isApplicationManagementHosting(obj: CMDBObject): obj is ApplicationManagementHosting { + return obj._objectType === 'ApplicationManagementHosting'; +} + +export function isApplicationManagementTam(obj: CMDBObject): obj is ApplicationManagementTam { + return obj._objectType === 'ApplicationManagementTam'; +} + +export function isApplicationManagementNumberOfUsers(obj: CMDBObject): obj is ApplicationManagementNumberOfUsers { + return obj._objectType === 'ApplicationManagementNumberOfUsers'; +} + +export function isCertificateType(obj: CMDBObject): obj is CertificateType { + return obj._objectType === 'CertificateType'; +} + +export function isApplicationFunctionCategory(obj: CMDBObject): obj is ApplicationFunctionCategory { + return obj._objectType === 'ApplicationFunctionCategory'; +} + +export function isApplicationFunction(obj: CMDBObject): obj is ApplicationFunction { + return obj._objectType === 'ApplicationFunction'; +} + +export function isCertificateClassificationType(obj: CMDBObject): obj is CertificateClassificationType { + return obj._objectType === 'CertificateClassificationType'; +} + +export function isBusinessImpactAnalyse(obj: CMDBObject): obj is BusinessImpactAnalyse { + return obj._objectType === 'BusinessImpactAnalyse'; +} + +export function isBusinessImportance(obj: CMDBObject): obj is BusinessImportance { + return obj._objectType === 'BusinessImportance'; +} + +export function isHostingType(obj: CMDBObject): obj is HostingType { + return obj._objectType === 'HostingType'; +} + +export function isOrganisation(obj: CMDBObject): obj is Organisation { + return obj._objectType === 'Organisation'; +} + +export function isApplicationManagementApplicationType(obj: CMDBObject): obj is ApplicationManagementApplicationType { + return obj._objectType === 'ApplicationManagementApplicationType'; +} + +export function isApplicationManagementDynamicsFactor(obj: CMDBObject): obj is ApplicationManagementDynamicsFactor { + return obj._objectType === 'ApplicationManagementDynamicsFactor'; +} + +export function isApplicationManagementComplexityFactor(obj: CMDBObject): obj is ApplicationManagementComplexityFactor { + return obj._objectType === 'ApplicationManagementComplexityFactor'; +} + +export function isIctGovernanceModel(obj: CMDBObject): obj is IctGovernanceModel { + return obj._objectType === 'IctGovernanceModel'; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 77dc368..d32def7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,13 +6,18 @@ import cookieParser from 'cookie-parser'; import { config, validateConfig } from './config/env.js'; import { logger } from './services/logger.js'; import { dataService } from './services/dataService.js'; +import { syncEngine } from './services/syncEngine.js'; +import { cmdbService } from './services/cmdbService.js'; import applicationsRouter from './routes/applications.js'; import classificationsRouter from './routes/classifications.js'; import referenceDataRouter from './routes/referenceData.js'; import dashboardRouter from './routes/dashboard.js'; import configurationRouter from './routes/configuration.js'; import authRouter, { authMiddleware } from './routes/auth.js'; -import { jiraAssetsService } from './services/jiraAssets.js'; +import searchRouter from './routes/search.js'; +import cacheRouter from './routes/cache.js'; +import objectsRouter from './routes/objects.js'; +import schemaRouter from './routes/schema.js'; // Validate configuration validateConfig(); @@ -50,16 +55,16 @@ app.use((req, res, next) => { // Auth middleware - extract session info for all requests app.use(authMiddleware); -// Set user token on JiraAssets service for each request +// Set user token on CMDBService for each request (for user-specific OAuth) app.use((req, res, next) => { // Set user's OAuth token if available if (req.accessToken) { - jiraAssetsService.setRequestToken(req.accessToken); + cmdbService.setUserToken(req.accessToken); } // Clear token after response is sent res.on('finish', () => { - jiraAssetsService.clearRequestToken(); + cmdbService.clearUserToken(); }); next(); @@ -68,12 +73,19 @@ app.use((req, res, next) => { // Health check app.get('/health', async (req, res) => { const jiraConnected = await dataService.testConnection(); + const cacheStatus = dataService.getCacheStatus(); + res.json({ status: 'ok', timestamp: new Date().toISOString(), - dataSource: dataService.isUsingJiraAssets() ? 'jira-assets' : 'mock-data', + dataSource: dataService.isUsingJiraAssets() ? 'jira-assets-cached' : 'mock-data', jiraConnected: dataService.isUsingJiraAssets() ? jiraConnected : null, aiConfigured: !!config.anthropicApiKey, + cache: { + isWarm: cacheStatus.isWarm, + objectCount: cacheStatus.totalObjects, + lastSync: cacheStatus.lastIncrementalSync, + }, }); }); @@ -91,6 +103,10 @@ app.use('/api/classifications', classificationsRouter); app.use('/api/reference-data', referenceDataRouter); app.use('/api/dashboard', dashboardRouter); app.use('/api/configuration', configurationRouter); +app.use('/api/search', searchRouter); +app.use('/api/cache', cacheRouter); +app.use('/api/objects', objectsRouter); +app.use('/api/schema', schemaRouter); // Error handling app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -108,20 +124,33 @@ app.use((req, res) => { // Start server const PORT = config.port; -app.listen(PORT, () => { +app.listen(PORT, async () => { logger.info(`Server running on http://localhost:${PORT}`); logger.info(`Environment: ${config.nodeEnv}`); logger.info(`AI Classification: ${config.anthropicApiKey ? 'Configured' : 'Not configured'}`); - logger.info(`Jira Assets: ${config.jiraPat ? 'Configured' : 'Using mock data'}`); + logger.info(`Jira Assets: ${config.jiraPat ? 'Configured with caching' : 'Using mock data'}`); + + // Initialize sync engine if using Jira Assets + if (config.jiraPat && config.jiraSchemaId) { + try { + await syncEngine.initialize(); + logger.info('Sync Engine: Initialized and running'); + } catch (error) { + logger.error('Failed to initialize sync engine', error); + } + } }); // Graceful shutdown -process.on('SIGTERM', () => { - logger.info('SIGTERM signal received: closing HTTP server'); +const shutdown = () => { + logger.info('Shutdown signal received: stopping services...'); + + // Stop sync engine + syncEngine.stop(); + + logger.info('Services stopped, exiting'); process.exit(0); -}); +}; -process.on('SIGINT', () => { - logger.info('SIGINT signal received: closing HTTP server'); - process.exit(0); -}); +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); diff --git a/backend/src/routes/applications.ts b/backend/src/routes/applications.ts index 4150a33..a59b83d 100644 --- a/backend/src/routes/applications.ts +++ b/backend/src/routes/applications.ts @@ -1,9 +1,11 @@ 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 { calculateRequiredEffortApplicationManagement, calculateRequiredEffortApplicationManagementWithBreakdown } from '../services/effortCalculation.js'; -import type { SearchFilters, ApplicationUpdateRequest, ReferenceValue, ClassificationResult, ApplicationDetails, ApplicationStatus } from '../types/index.js'; +import { calculateRequiredEffortApplicationManagementWithBreakdown } from '../services/effortCalculation.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(); @@ -50,9 +52,12 @@ router.get('/team-dashboard', async (req: Request, res: Response) => { }); // 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 = req.query.mode as string | undefined; // Don't treat special routes as application IDs if (id === 'team-dashboard' || id === 'calculate-effort' || id === 'search') { @@ -60,7 +65,10 @@ router.get('/:id', async (req: Request, res: Response) => { return; } - const application = await dataService.getApplicationById(id); + // 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' }); @@ -74,19 +82,33 @@ router.get('/:id', async (req: Request, res: Response) => { } }); -// Update application +// Update application with conflict detection router.put('/:id', async (req: Request, res: Response) => { try { const { id } = req.params; - const updates = req.body as { - applicationFunctions?: ReferenceValue[]; - dynamicsFactor?: ReferenceValue; - complexityFactor?: ReferenceValue; - numberOfUsers?: ReferenceValue; - governanceModel?: ReferenceValue; - source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL'; + 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' }); @@ -95,61 +117,111 @@ router.put('/:id', async (req: Request, res: Response) => { // Build changes object for history const changes: ClassificationResult['changes'] = {}; - if (updates.applicationFunctions) { + if (actualUpdates.applicationFunctions) { changes.applicationFunctions = { from: application.applicationFunctions, - to: updates.applicationFunctions, + to: actualUpdates.applicationFunctions, }; } - if (updates.dynamicsFactor) { + if (actualUpdates.dynamicsFactor) { changes.dynamicsFactor = { from: application.dynamicsFactor, - to: updates.dynamicsFactor, + to: actualUpdates.dynamicsFactor, }; } - if (updates.complexityFactor) { + if (actualUpdates.complexityFactor) { changes.complexityFactor = { from: application.complexityFactor, - to: updates.complexityFactor, + to: actualUpdates.complexityFactor, }; } - if (updates.numberOfUsers) { + if (actualUpdates.numberOfUsers) { changes.numberOfUsers = { from: application.numberOfUsers, - to: updates.numberOfUsers, + to: actualUpdates.numberOfUsers, }; } - if (updates.governanceModel) { + if (actualUpdates.governanceModel) { changes.governanceModel = { from: application.governanceModel, - to: updates.governanceModel, + to: actualUpdates.governanceModel, }; } - const success = await dataService.updateApplication(id, updates); + // Call updateApplication with conflict detection if _jiraUpdatedAt is provided + const result = await dataService.updateApplication(id, actualUpdates, _jiraUpdatedAt); - if (success) { - // Save to classification history - const classificationResult: ClassificationResult = { - applicationId: id, - applicationName: application.name, - changes, - source: updates.source || 'MANUAL', - timestamp: new Date(), - }; - databaseService.saveClassificationResult(classificationResult); - - const updatedApp = await dataService.getApplicationById(id); - res.json(updatedApp); - } else { - res.status(500).json({ error: 'Failed to update application' }); + // 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(), + }; + 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 { @@ -180,7 +252,8 @@ router.post('/calculate-effort', async (req: Request, res: Response) => { complexityFactor: applicationData.complexityFactor || null, numberOfUsers: applicationData.numberOfUsers || null, governanceModel: applicationData.governanceModel || null, - applicationCluster: applicationData.applicationCluster || null, + applicationSubteam: applicationData.applicationSubteam || null, + applicationTeam: applicationData.applicationTeam || null, applicationType: applicationData.applicationType || null, platform: applicationData.platform || null, requiredEffortApplicationManagement: null, @@ -214,4 +287,120 @@ router.get('/:id/history', async (req: Request, res: Response) => { } }); +// 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 requestedAttrs = req.query.attributes + ? String(req.query.attributes).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 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; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 4722b1a..313670b 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -18,9 +18,14 @@ declare global { // Get auth configuration router.get('/config', (req: Request, res: Response) => { + const authMethod = authService.getAuthMethod(); res.json({ + // Configured authentication method ('pat', 'oauth', or 'none') + authMethod, + // Legacy fields for backward compatibility oauthEnabled: authService.isOAuthEnabled(), serviceAccountEnabled: authService.isUsingServiceAccount(), + // Jira host for display purposes jiraHost: config.jiraHost, }); }); diff --git a/backend/src/routes/cache.ts b/backend/src/routes/cache.ts new file mode 100644 index 0000000..20d748a --- /dev/null +++ b/backend/src/routes/cache.ts @@ -0,0 +1,135 @@ +/** + * Cache management routes + * + * Provides endpoints for cache status and manual sync triggers. + */ + +import { Router, Request, Response } from 'express'; +import { cacheStore } from '../services/cacheStore.js'; +import { syncEngine } from '../services/syncEngine.js'; +import { logger } from '../services/logger.js'; +import { OBJECT_TYPES } from '../generated/jira-schema.js'; +import type { CMDBObjectTypeName } from '../generated/jira-types.js'; + +const router = Router(); + +// Get cache status +router.get('/status', (req: Request, res: Response) => { + try { + const cacheStats = cacheStore.getStats(); + const syncStatus = syncEngine.getStatus(); + + res.json({ + cache: cacheStats, + sync: syncStatus, + supportedTypes: Object.keys(OBJECT_TYPES), + }); + } catch (error) { + logger.error('Failed to get cache status', error); + res.status(500).json({ error: 'Failed to get cache status' }); + } +}); + +// Trigger full sync +router.post('/sync', async (req: Request, res: Response) => { + try { + logger.info('Manual full sync triggered'); + + // Don't wait for completion - return immediately + syncEngine.fullSync().catch(err => { + logger.error('Full sync failed', err); + }); + + res.json({ + status: 'started', + message: 'Full sync started in background', + }); + } catch (error) { + logger.error('Failed to trigger full sync', error); + res.status(500).json({ error: 'Failed to trigger sync' }); + } +}); + +// Trigger sync for a specific object type +router.post('/sync/:objectType', async (req: Request, res: Response) => { + try { + const { objectType } = req.params; + + // Validate object type + if (!OBJECT_TYPES[objectType]) { + res.status(400).json({ + error: `Unknown object type: ${objectType}`, + supportedTypes: Object.keys(OBJECT_TYPES), + }); + return; + } + + logger.info(`Manual sync triggered for ${objectType}`); + + const result = await syncEngine.syncType(objectType as CMDBObjectTypeName); + + res.json({ + status: 'completed', + objectType, + stats: result, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to sync object type'; + logger.error(`Failed to sync object type ${req.params.objectType}`, error); + + // Return 409 (Conflict) if sync is already in progress, otherwise 500 + const statusCode = errorMessage.includes('already in progress') ? 409 : 500; + res.status(statusCode).json({ + error: errorMessage, + objectType: req.params.objectType, + }); + } +}); + +// Clear cache for a specific type +router.delete('/clear/:objectType', (req: Request, res: Response) => { + try { + const { objectType } = req.params; + + if (!OBJECT_TYPES[objectType]) { + res.status(400).json({ + error: `Unknown object type: ${objectType}`, + supportedTypes: Object.keys(OBJECT_TYPES), + }); + return; + } + + logger.info(`Clearing cache for ${objectType}`); + + const deleted = cacheStore.clearObjectType(objectType as CMDBObjectTypeName); + + res.json({ + status: 'cleared', + objectType, + deletedCount: deleted, + }); + } catch (error) { + logger.error(`Failed to clear cache for ${req.params.objectType}`, error); + res.status(500).json({ error: 'Failed to clear cache' }); + } +}); + +// Clear entire cache +router.delete('/clear', (req: Request, res: Response) => { + try { + logger.info('Clearing entire cache'); + + cacheStore.clearAll(); + + res.json({ + status: 'cleared', + message: 'Entire cache cleared', + }); + } catch (error) { + logger.error('Failed to clear cache', error); + res.status(500).json({ error: 'Failed to clear cache' }); + } +}); + +export default router; + diff --git a/backend/src/routes/configuration.ts b/backend/src/routes/configuration.ts index a3d43f5..c205184 100644 --- a/backend/src/routes/configuration.ts +++ b/backend/src/routes/configuration.ts @@ -1,10 +1,15 @@ import { Router, Request, Response } from 'express'; import { readFile, writeFile } from 'fs/promises'; -import { join } from 'path'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; import { logger } from '../services/logger.js'; import { clearEffortCalculationConfigCache, getEffortCalculationConfigV25 } from '../services/effortCalculation.js'; import type { EffortCalculationConfig, EffortCalculationConfigV25 } from '../config/effortCalculation.js'; +// Get __dirname equivalent for ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + const router = Router(); // Path to the configuration files diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 3764670..d208e4c 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -1,18 +1,21 @@ import { Router, Request, Response } from 'express'; import { dataService } from '../services/dataService.js'; import { databaseService } from '../services/database.js'; +import { syncEngine } from '../services/syncEngine.js'; import { logger } from '../services/logger.js'; +import { validateApplicationConfiguration } from '../services/effortCalculation.js'; +import type { ApplicationDetails, ApplicationStatus, ReferenceValue } from '../types/index.js'; const router = Router(); // Simple in-memory cache for dashboard stats interface CachedStats { - data: any; + data: unknown; timestamp: number; } let statsCache: CachedStats | null = null; -const CACHE_TTL_MS = 3 * 60 * 1000; // 3 minutes cache (longer since jiraAssets also caches) +const CACHE_TTL_MS = 3 * 60 * 1000; // 3 minutes cache // Get dashboard statistics router.get('/stats', async (req: Request, res: Response) => { @@ -24,7 +27,8 @@ router.get('/stats', async (req: Request, res: Response) => { const now = Date.now(); if (!forceRefresh && statsCache && (now - statsCache.timestamp) < CACHE_TTL_MS) { logger.debug('Returning cached dashboard stats'); - return res.json(statsCache.data); + res.json(statsCache.data); + return; } logger.info('Dashboard: Fetching fresh stats...'); @@ -33,10 +37,29 @@ router.get('/stats', async (req: Request, res: Response) => { const includeDistributions = req.query.distributions !== 'false'; const stats = await dataService.getStats(includeDistributions); const dbStats = databaseService.getStats(); + + // Get cache status + const cacheStatus = dataService.getCacheStatus(); + const syncStatus = syncEngine.getStatus(); const responseData = { ...stats, classificationStats: dbStats, + cache: { + lastFullSync: cacheStatus.lastFullSync, + lastIncrementalSync: cacheStatus.lastIncrementalSync, + objectCount: cacheStatus.totalObjects, + objectsByType: cacheStatus.objectsByType, + totalRelations: cacheStatus.totalRelations, + isWarm: cacheStatus.isWarm, + dbSizeBytes: cacheStatus.dbSizeBytes, + syncStatus: { + isRunning: syncStatus.isRunning, + isSyncing: syncStatus.isSyncing, + nextIncrementalSync: syncStatus.nextIncrementalSync, + incrementalInterval: syncStatus.incrementalInterval, + }, + }, }; // Update cache @@ -53,11 +76,12 @@ router.get('/stats', async (req: Request, res: Response) => { // Return cached data if available (even if expired) if (statsCache) { logger.info('Dashboard: Returning stale cached data due to error'); - return res.json({ - ...statsCache.data, + res.json({ + ...statsCache.data as object, stale: true, error: 'Using cached data due to API timeout', }); + return; } res.status(500).json({ error: 'Failed to get dashboard stats' }); @@ -76,4 +100,101 @@ router.get('/recent', (req: Request, res: Response) => { } }); +// Get applications with governance model validation issues +router.get('/governance-analysis', async (req: Request, res: Response) => { + try { + logger.info('Governance Analysis: Fetching all applications for validation...'); + + // Use batched fetching to avoid timeouts + const pageSize = 50; // Smaller batch size for reliability + // Include all statuses so they can be filtered client-side (including Closed) + const statuses: ApplicationStatus[] = ['In Production', 'Implementation', 'Proof of Concept', 'End of support', 'End of life', 'Deprecated', 'Shadow IT', 'Undefined', 'Closed']; + + let allApplications: Array<{ id: string; key: string; name: string; status: ApplicationStatus | null; governanceModel?: ReferenceValue | null; applicationType?: ReferenceValue | null }> = []; + let currentPage = 1; + let totalCount = 0; + let hasMore = true; + + // Fetch applications in batches + while (hasMore) { + try { + const searchResult = await dataService.searchApplications( + { statuses }, + currentPage, + pageSize + ); + + if (currentPage === 1) { + totalCount = searchResult.totalCount; + logger.info(`Governance Analysis: Total applications to process: ${totalCount}`); + } + + allApplications = allApplications.concat(searchResult.applications as typeof allApplications); + hasMore = searchResult.applications.length === pageSize && allApplications.length < totalCount; + currentPage++; + + // Safety limit to prevent infinite loops + if (currentPage > 100) { + logger.warn('Governance Analysis: Reached page limit, stopping fetch'); + break; + } + } catch (fetchError) { + logger.error(`Governance Analysis: Error fetching page ${currentPage}`, fetchError); + // Continue with what we have if a single batch fails + hasMore = false; + } + } + + logger.info(`Governance Analysis: Fetched ${allApplications.length} applications, validating...`); + + const applicationsWithIssues: Array<{ + id: string; + key: string; + name: string; + status: string | null; + governanceModel: string | null; + businessImpactAnalyse: string | null; + applicationType: string | null; + warnings: string[]; + errors: string[]; + }> = []; + + // Process each application + for (const app of allApplications) { + // Get full application details for validation + const fullApp = await dataService.getApplicationById(app.id); + if (!fullApp) continue; + + const validation = validateApplicationConfiguration(fullApp as ApplicationDetails); + + // Only include applications with ERRORS (red warnings) + // Applications with only warnings (yellow) are excluded + if (validation.errors.length > 0) { + applicationsWithIssues.push({ + id: app.id, + key: app.key, + name: app.name, + status: app.status, + governanceModel: app.governanceModel?.name || null, + businessImpactAnalyse: fullApp.businessImpactAnalyse?.name || null, + applicationType: app.applicationType?.name || null, + warnings: validation.warnings, + errors: validation.errors, + }); + } + } + + logger.info(`Governance Analysis: Found ${applicationsWithIssues.length} applications with validation issues`); + + res.json({ + totalApplications: totalCount, + applicationsWithIssues: applicationsWithIssues.length, + applications: applicationsWithIssues, + }); + } catch (error) { + logger.error('Failed to get governance analysis', error); + res.status(500).json({ error: 'Failed to get governance analysis' }); + } +}); + export default router; diff --git a/backend/src/routes/objects.ts b/backend/src/routes/objects.ts new file mode 100644 index 0000000..a495b44 --- /dev/null +++ b/backend/src/routes/objects.ts @@ -0,0 +1,176 @@ +/** + * Generic object routes + * + * Provides schema-driven access to all CMDB object types. + */ + +import { Router, Request, Response } from 'express'; +import { cmdbService } from '../services/cmdbService.js'; +import { logger } from '../services/logger.js'; +import { OBJECT_TYPES } from '../generated/jira-schema.js'; +import type { CMDBObject, CMDBObjectTypeName } from '../generated/jira-types.js'; + +const router = Router(); + +// Get list of supported object types +router.get('/', (req: Request, res: Response) => { + const types = Object.entries(OBJECT_TYPES).map(([typeName, def]) => ({ + typeName, + jiraTypeId: def.jiraTypeId, + name: def.name, + syncPriority: def.syncPriority, + attributeCount: def.attributes.length, + })); + + res.json({ + types, + totalTypes: types.length, + }); +}); + +// Get all objects of a type +router.get('/:type', async (req: Request, res: Response) => { + try { + const { type } = req.params; + const limit = parseInt(req.query.limit as string) || 1000; + const offset = parseInt(req.query.offset as string) || 0; + const search = req.query.search as string | undefined; + + // Validate type + if (!OBJECT_TYPES[type]) { + res.status(400).json({ + error: `Unknown object type: ${type}`, + supportedTypes: Object.keys(OBJECT_TYPES), + }); + return; + } + + const objects = await cmdbService.getObjects(type as CMDBObjectTypeName, { + limit, + offset, + searchTerm: search, + }); + + const count = cmdbService.countObjects(type as CMDBObjectTypeName); + + res.json({ + objectType: type, + objects, + count: objects.length, + totalCount: count, + offset, + limit, + }); + } catch (error) { + logger.error(`Failed to get objects of type ${req.params.type}`, error); + res.status(500).json({ error: 'Failed to get objects' }); + } +}); + +// Get a specific object by ID +router.get('/:type/:id', async (req: Request, res: Response) => { + try { + const { type, id } = req.params; + const forceRefresh = req.query.refresh === 'true'; + + // Validate type + if (!OBJECT_TYPES[type]) { + res.status(400).json({ + error: `Unknown object type: ${type}`, + supportedTypes: Object.keys(OBJECT_TYPES), + }); + return; + } + + const object = await cmdbService.getObject(type as CMDBObjectTypeName, id, { + forceRefresh, + }); + + if (!object) { + res.status(404).json({ error: 'Object not found' }); + return; + } + + res.json(object); + } catch (error) { + logger.error(`Failed to get object ${req.params.type}/${req.params.id}`, error); + res.status(500).json({ error: 'Failed to get object' }); + } +}); + +// Get related objects +router.get('/:type/:id/related/:relationType', async (req: Request, res: Response) => { + try { + const { type, id, relationType } = req.params; + const attribute = req.query.attribute as string | undefined; + + // Validate types + if (!OBJECT_TYPES[type]) { + res.status(400).json({ error: `Unknown source type: ${type}` }); + return; + } + + if (!OBJECT_TYPES[relationType]) { + res.status(400).json({ error: `Unknown relation type: ${relationType}` }); + return; + } + + const relatedObjects = await cmdbService.getRelatedObjects( + id, + attribute || '', + relationType as CMDBObjectTypeName + ); + + res.json({ + sourceId: id, + sourceType: type, + relationType, + attribute: attribute || null, + objects: relatedObjects, + count: relatedObjects.length, + }); + } catch (error) { + logger.error(`Failed to get related objects`, error); + res.status(500).json({ error: 'Failed to get related objects' }); + } +}); + +// Get objects referencing this object (inbound references) +router.get('/:type/:id/referenced-by/:sourceType', async (req: Request, res: Response) => { + try { + const { type, id, sourceType } = req.params; + const attribute = req.query.attribute as string | undefined; + + // Validate types + if (!OBJECT_TYPES[type]) { + res.status(400).json({ error: `Unknown target type: ${type}` }); + return; + } + + if (!OBJECT_TYPES[sourceType]) { + res.status(400).json({ error: `Unknown source type: ${sourceType}` }); + return; + } + + const referencingObjects = await cmdbService.getReferencingObjects( + id, + sourceType as CMDBObjectTypeName, + attribute + ); + + res.json({ + targetId: id, + targetType: type, + sourceType, + attribute: attribute || null, + objects: referencingObjects, + count: referencingObjects.length, + }); + } catch (error) { + logger.error(`Failed to get referencing objects`, error); + res.status(500).json({ error: 'Failed to get referencing objects' }); + } +}); + +export default router; + diff --git a/backend/src/routes/referenceData.ts b/backend/src/routes/referenceData.ts index ac8d335..8231375 100644 --- a/backend/src/routes/referenceData.ts +++ b/backend/src/routes/referenceData.ts @@ -15,12 +15,14 @@ router.get('/', async (req: Request, res: Response) => { organisations, hostingTypes, applicationFunctions, - applicationClusters, + applicationSubteams, + applicationTeams, applicationTypes, businessImportance, businessImpactAnalyses, applicationManagementHosting, applicationManagementTAM, + subteamToTeamMapping, ] = await Promise.all([ dataService.getDynamicsFactors(), dataService.getComplexityFactors(), @@ -29,12 +31,14 @@ router.get('/', async (req: Request, res: Response) => { dataService.getOrganisations(), dataService.getHostingTypes(), dataService.getApplicationFunctions(), - dataService.getApplicationClusters(), + dataService.getApplicationSubteams(), + dataService.getApplicationTeams(), dataService.getApplicationTypes(), dataService.getBusinessImportance(), dataService.getBusinessImpactAnalyses(), dataService.getApplicationManagementHosting(), dataService.getApplicationManagementTAM(), + dataService.getSubteamToTeamMapping(), ]); res.json({ @@ -45,12 +49,14 @@ router.get('/', async (req: Request, res: Response) => { organisations, hostingTypes, applicationFunctions, - applicationClusters, + applicationSubteams, + applicationTeams, applicationTypes, businessImportance, businessImpactAnalyses, applicationManagementHosting, applicationManagementTAM, + subteamToTeamMapping, }); } catch (error) { logger.error('Failed to get reference data', error); @@ -135,14 +141,25 @@ router.get('/application-functions', async (req: Request, res: Response) => { } }); -// Get application clusters (from Jira Assets) -router.get('/application-clusters', async (req: Request, res: Response) => { +// Get application subteams (from Jira Assets) +router.get('/application-subteams', async (req: Request, res: Response) => { try { - const clusters = await dataService.getApplicationClusters(); - res.json(clusters); + const subteams = await dataService.getApplicationSubteams(); + res.json(subteams); } catch (error) { - logger.error('Failed to get application clusters', error); - res.status(500).json({ error: 'Failed to get application clusters' }); + logger.error('Failed to get application subteams', error); + res.status(500).json({ error: 'Failed to get application subteams' }); + } +}); + +// Get application teams (from Jira Assets) +router.get('/application-teams', async (req: Request, res: Response) => { + try { + const teams = await dataService.getApplicationTeams(); + res.json(teams); + } catch (error) { + logger.error('Failed to get application teams', error); + res.status(500).json({ error: 'Failed to get application teams' }); } }); diff --git a/backend/src/routes/schema.ts b/backend/src/routes/schema.ts new file mode 100644 index 0000000..2dce0c2 --- /dev/null +++ b/backend/src/routes/schema.ts @@ -0,0 +1,151 @@ +import { Router } from 'express'; +import { OBJECT_TYPES, SCHEMA_GENERATED_AT, SCHEMA_OBJECT_TYPE_COUNT, SCHEMA_TOTAL_ATTRIBUTES } from '../generated/jira-schema.js'; +import type { ObjectTypeDefinition, AttributeDefinition } from '../generated/jira-schema.js'; + +const router = Router(); + +// Extended types for API response +interface ObjectTypeWithLinks extends ObjectTypeDefinition { + incomingLinks: Array<{ + fromType: string; + fromTypeName: string; + attributeName: string; + isMultiple: boolean; + }>; + outgoingLinks: Array<{ + toType: string; + toTypeName: string; + attributeName: string; + isMultiple: boolean; + }>; +} + +interface SchemaResponse { + metadata: { + generatedAt: string; + objectTypeCount: number; + totalAttributes: number; + }; + objectTypes: Record; +} + +/** + * GET /api/schema + * Returns the complete Jira Assets schema with object types, attributes, and links + */ +router.get('/', (req, res) => { + try { + // Build links between object types + const objectTypesWithLinks: Record = {}; + + // First pass: convert all object types + for (const [typeName, typeDef] of Object.entries(OBJECT_TYPES)) { + objectTypesWithLinks[typeName] = { + ...typeDef, + incomingLinks: [], + outgoingLinks: [], + }; + } + + // Second pass: build link relationships + for (const [typeName, typeDef] of Object.entries(OBJECT_TYPES)) { + for (const attr of typeDef.attributes) { + if (attr.type === 'reference' && attr.referenceTypeName) { + // Add outgoing link from this type + objectTypesWithLinks[typeName].outgoingLinks.push({ + toType: attr.referenceTypeName, + toTypeName: OBJECT_TYPES[attr.referenceTypeName]?.name || attr.referenceTypeName, + attributeName: attr.name, + isMultiple: attr.isMultiple, + }); + + // Add incoming link to the referenced type + if (objectTypesWithLinks[attr.referenceTypeName]) { + objectTypesWithLinks[attr.referenceTypeName].incomingLinks.push({ + fromType: typeName, + fromTypeName: typeDef.name, + attributeName: attr.name, + isMultiple: attr.isMultiple, + }); + } + } + } + } + + const response: SchemaResponse = { + metadata: { + generatedAt: SCHEMA_GENERATED_AT, + objectTypeCount: SCHEMA_OBJECT_TYPE_COUNT, + totalAttributes: SCHEMA_TOTAL_ATTRIBUTES, + }, + objectTypes: objectTypesWithLinks, + }; + + res.json(response); + } catch (error) { + console.error('Failed to get schema:', error); + res.status(500).json({ error: 'Failed to get schema' }); + } +}); + +/** + * GET /api/schema/object-type/:typeName + * Returns details for a specific object type + */ +router.get('/object-type/:typeName', (req, res) => { + const { typeName } = req.params; + + const typeDef = OBJECT_TYPES[typeName]; + if (!typeDef) { + return res.status(404).json({ error: `Object type '${typeName}' not found` }); + } + + // Build links for this specific type + const incomingLinks: Array<{ + fromType: string; + fromTypeName: string; + attributeName: string; + isMultiple: boolean; + }> = []; + + const outgoingLinks: Array<{ + toType: string; + toTypeName: string; + attributeName: string; + isMultiple: boolean; + }> = []; + + // Outgoing links from this type + for (const attr of typeDef.attributes) { + if (attr.type === 'reference' && attr.referenceTypeName) { + outgoingLinks.push({ + toType: attr.referenceTypeName, + toTypeName: OBJECT_TYPES[attr.referenceTypeName]?.name || attr.referenceTypeName, + attributeName: attr.name, + isMultiple: attr.isMultiple, + }); + } + } + + // Incoming links from other types + for (const [otherTypeName, otherTypeDef] of Object.entries(OBJECT_TYPES)) { + for (const attr of otherTypeDef.attributes) { + if (attr.type === 'reference' && attr.referenceTypeName === typeName) { + incomingLinks.push({ + fromType: otherTypeName, + fromTypeName: otherTypeDef.name, + attributeName: attr.name, + isMultiple: attr.isMultiple, + }); + } + } + } + + res.json({ + ...typeDef, + incomingLinks, + outgoingLinks, + }); +}); + +export default router; diff --git a/backend/src/routes/search.ts b/backend/src/routes/search.ts new file mode 100644 index 0000000..0492bc4 --- /dev/null +++ b/backend/src/routes/search.ts @@ -0,0 +1,74 @@ +import { Router, Request, Response } from 'express'; +import { cmdbService } from '../services/cmdbService.js'; +import { logger } from '../services/logger.js'; +import { config } from '../config/env.js'; + +const router = Router(); + +// CMDB free-text search endpoint (from cache) +router.get('/', async (req: Request, res: Response) => { + try { + const query = req.query.query as string; + const limit = parseInt(req.query.limit as string, 10) || 100; + + if (!query || query.trim().length === 0) { + res.status(400).json({ error: 'Query parameter is required' }); + return; + } + + logger.info(`CMDB search request: query="${query}", limit=${limit}`); + + // Search all types in cache + const results = await cmdbService.searchAllTypes(query.trim(), { limit }); + + // Group results by object type + const objectTypeMap = new Map(); + const formattedResults = results.map(obj => { + const typeName = obj._objectType || 'Unknown'; + + // Track unique object types + if (!objectTypeMap.has(typeName)) { + objectTypeMap.set(typeName, { + id: objectTypeMap.size + 1, + name: typeName, + iconUrl: '', // Can be enhanced to include actual icons + }); + } + + const objectType = objectTypeMap.get(typeName)!; + + return { + id: parseInt(obj.id, 10) || 0, + key: obj.objectKey, + label: obj.label, + objectTypeId: objectType.id, + avatarUrl: '', + attributes: [], // Can be enhanced to include attributes + }; + }); + + // Build response matching CMDBSearchResponse interface + const response = { + metadata: { + count: formattedResults.length, + offset: 0, + limit: limit, + total: formattedResults.length, + criteria: { + query: query, + type: 'global', + schema: parseInt(config.jiraSchemaId, 10) || 0, + }, + }, + objectTypes: Array.from(objectTypeMap.values()), + results: formattedResults, + }; + + res.json(response); + } catch (error) { + logger.error('CMDB search failed', error); + res.status(500).json({ error: 'Failed to search CMDB' }); + } +}); + +export default router; diff --git a/backend/src/services/authService.ts b/backend/src/services/authService.ts index fa8facb..e791fbe 100644 --- a/backend/src/services/authService.ts +++ b/backend/src/services/authService.ts @@ -268,14 +268,21 @@ class AuthService { return existed; } - // Check if OAuth is enabled + // Check if OAuth is enabled (jiraAuthMethod = 'oauth') isOAuthEnabled(): boolean { - return config.jiraOAuthEnabled && !!config.jiraOAuthClientId && !!config.jiraOAuthClientSecret; + return config.jiraAuthMethod === 'oauth' && !!config.jiraOAuthClientId && !!config.jiraOAuthClientSecret; } - // Check if using service account (PAT) fallback + // Check if using service account (PAT) mode (jiraAuthMethod = 'pat') isUsingServiceAccount(): boolean { - return !this.isOAuthEnabled() && !!config.jiraPat; + return config.jiraAuthMethod === 'pat' && !!config.jiraPat; + } + + // Get the configured authentication method + getAuthMethod(): 'pat' | 'oauth' | 'none' { + if (this.isOAuthEnabled()) return 'oauth'; + if (this.isUsingServiceAccount()) return 'pat'; + return 'none'; } } diff --git a/backend/src/services/cacheStore.ts b/backend/src/services/cacheStore.ts new file mode 100644 index 0000000..f7fe425 --- /dev/null +++ b/backend/src/services/cacheStore.ts @@ -0,0 +1,660 @@ +/** + * CacheStore - SQLite cache operations for CMDB objects + * + * Provides fast local storage for CMDB data synced from Jira Assets. + * Uses the generated schema for type-safe operations. + */ + +import Database from 'better-sqlite3'; +import { join, dirname } from 'path'; +import * as path from 'path'; +import * as fs from 'fs'; +import { logger } from './logger.js'; +import type { CMDBObject, CMDBObjectTypeName, ObjectReference } from '../generated/jira-types.js'; +import { getReferenceAttributes } from '../generated/jira-schema.js'; + +// Get current directory for ESM +const currentFileUrl = new URL(import.meta.url); +const __dirname = dirname(currentFileUrl.pathname); + +const CACHE_DB_PATH = join(__dirname, '../../data/cmdb-cache.db'); + +export interface CacheStats { + totalObjects: number; + objectsByType: Record; + totalRelations: number; + lastFullSync: string | null; + lastIncrementalSync: string | null; + isWarm: boolean; + dbSizeBytes: number; +} + +export interface QueryOptions { + limit?: number; + offset?: number; + orderBy?: string; + orderDir?: 'ASC' | 'DESC'; +} + +class CacheStore { + private db: Database.Database; + private initialized: boolean = false; + + constructor() { + // Ensure data directory exists + const dataDir = dirname(CACHE_DB_PATH); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + + this.db = new Database(CACHE_DB_PATH); + this.initialize(); + } + + private initialize(): void { + if (this.initialized) return; + + // Read and execute the generated schema + const schemaPath = join(__dirname, '../generated/db-schema.sql'); + + if (fs.existsSync(schemaPath)) { + const schema = fs.readFileSync(schemaPath, 'utf-8'); + this.db.exec(schema); + logger.info('CacheStore: Database schema initialized from generated file'); + } else { + // Fallback: create tables directly + this.db.exec(` + CREATE TABLE IF NOT EXISTS cached_objects ( + id TEXT PRIMARY KEY, + object_key TEXT NOT NULL UNIQUE, + object_type TEXT NOT NULL, + label TEXT NOT NULL, + data JSON NOT NULL, + jira_updated_at TEXT, + jira_created_at TEXT, + cached_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS object_relations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + attribute_name TEXT NOT NULL, + source_type TEXT NOT NULL, + target_type TEXT NOT NULL, + UNIQUE(source_id, target_id, attribute_name) + ); + + CREATE TABLE IF NOT EXISTS sync_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_objects_type ON cached_objects(object_type); + CREATE INDEX IF NOT EXISTS idx_objects_key ON cached_objects(object_key); + CREATE INDEX IF NOT EXISTS idx_objects_updated ON cached_objects(jira_updated_at); + CREATE INDEX IF NOT EXISTS idx_objects_label ON cached_objects(label); + + CREATE INDEX IF NOT EXISTS idx_relations_source ON object_relations(source_id); + CREATE INDEX IF NOT EXISTS idx_relations_target ON object_relations(target_id); + CREATE INDEX IF NOT EXISTS idx_relations_source_type ON object_relations(source_type); + CREATE INDEX IF NOT EXISTS idx_relations_target_type ON object_relations(target_type); + CREATE INDEX IF NOT EXISTS idx_relations_attr ON object_relations(attribute_name); + `); + logger.info('CacheStore: Database schema initialized (fallback)'); + } + + this.initialized = true; + } + + // ========================================================================== + // Object CRUD Operations + // ========================================================================== + + /** + * Get a single object by ID + */ + getObject(typeName: CMDBObjectTypeName, id: string): T | null { + const stmt = this.db.prepare(` + SELECT data FROM cached_objects + WHERE id = ? AND object_type = ? + `); + const row = stmt.get(id, typeName) as { data: string } | undefined; + + if (!row) return null; + + try { + return JSON.parse(row.data) as T; + } catch (error) { + logger.error(`CacheStore: Failed to parse object ${id}`, error); + return null; + } + } + + /** + * Get a single object by object key (e.g., "ICMT-123") + */ + getObjectByKey(typeName: CMDBObjectTypeName, objectKey: string): T | null { + const stmt = this.db.prepare(` + SELECT data FROM cached_objects + WHERE object_key = ? AND object_type = ? + `); + const row = stmt.get(objectKey, typeName) as { data: string } | undefined; + + if (!row) return null; + + try { + return JSON.parse(row.data) as T; + } catch (error) { + logger.error(`CacheStore: Failed to parse object ${objectKey}`, error); + return null; + } + } + + /** + * Get all objects of a specific type + */ + getObjects( + typeName: CMDBObjectTypeName, + options?: QueryOptions + ): T[] { + const limit = options?.limit || 10000; + const offset = options?.offset || 0; + const orderBy = options?.orderBy || 'label'; + const orderDir = options?.orderDir || 'ASC'; + + const stmt = this.db.prepare(` + SELECT data FROM cached_objects + WHERE object_type = ? + ORDER BY ${orderBy} ${orderDir} + LIMIT ? OFFSET ? + `); + + const rows = stmt.all(typeName, limit, offset) as { data: string }[]; + + return rows.map(row => { + try { + return JSON.parse(row.data) as T; + } catch { + return null; + } + }).filter((obj): obj is T => obj !== null); + } + + /** + * Count objects of a specific type + */ + countObjects(typeName: CMDBObjectTypeName): number { + const stmt = this.db.prepare(` + SELECT COUNT(*) as count FROM cached_objects + WHERE object_type = ? + `); + const row = stmt.get(typeName) as { count: number }; + return row.count; + } + + /** + * Search objects by label (case-insensitive) + */ + searchByLabel( + typeName: CMDBObjectTypeName, + searchTerm: string, + options?: QueryOptions + ): T[] { + const limit = options?.limit || 100; + const offset = options?.offset || 0; + + const stmt = this.db.prepare(` + SELECT data FROM cached_objects + WHERE object_type = ? AND label LIKE ? + ORDER BY label ASC + LIMIT ? OFFSET ? + `); + + const rows = stmt.all(typeName, `%${searchTerm}%`, limit, offset) as { data: string }[]; + + return rows.map(row => { + try { + return JSON.parse(row.data) as T; + } catch { + return null; + } + }).filter((obj): obj is T => obj !== null); + } + + /** + * Search across all object types + */ + searchAllTypes(searchTerm: string, options?: QueryOptions): CMDBObject[] { + const limit = options?.limit || 100; + const offset = options?.offset || 0; + + const stmt = this.db.prepare(` + SELECT data FROM cached_objects + WHERE label LIKE ? OR object_key LIKE ? + ORDER BY object_type, label ASC + LIMIT ? OFFSET ? + `); + + const pattern = `%${searchTerm}%`; + const rows = stmt.all(pattern, pattern, limit, offset) as { data: string }[]; + + return rows.map(row => { + try { + return JSON.parse(row.data) as CMDBObject; + } catch { + return null; + } + }).filter((obj): obj is CMDBObject => obj !== null); + } + + /** + * Upsert a single object + */ + upsertObject(typeName: CMDBObjectTypeName, object: T): void { + const stmt = this.db.prepare(` + INSERT INTO cached_objects (id, object_key, object_type, label, data, jira_updated_at, jira_created_at, cached_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + object_key = excluded.object_key, + label = excluded.label, + data = excluded.data, + jira_updated_at = excluded.jira_updated_at, + cached_at = excluded.cached_at + `); + + stmt.run( + object.id, + object.objectKey, + typeName, + object.label, + JSON.stringify(object), + object._jiraUpdatedAt || null, + object._jiraCreatedAt || null, + new Date().toISOString() + ); + } + + /** + * Batch upsert objects (much faster for bulk operations) + */ + batchUpsertObjects(typeName: CMDBObjectTypeName, objects: T[]): void { + if (objects.length === 0) return; + + const stmt = this.db.prepare(` + INSERT INTO cached_objects (id, object_key, object_type, label, data, jira_updated_at, jira_created_at, cached_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + object_key = excluded.object_key, + label = excluded.label, + data = excluded.data, + jira_updated_at = excluded.jira_updated_at, + cached_at = excluded.cached_at + `); + + const now = new Date().toISOString(); + const batchInsert = this.db.transaction((objs: T[]) => { + for (const obj of objs) { + stmt.run( + obj.id, + obj.objectKey, + typeName, + obj.label, + JSON.stringify(obj), + obj._jiraUpdatedAt || null, + obj._jiraCreatedAt || null, + now + ); + } + }); + + batchInsert(objects); + logger.debug(`CacheStore: Batch upserted ${objects.length} ${typeName} objects`); + } + + /** + * Delete an object by ID + */ + deleteObject(typeName: CMDBObjectTypeName, id: string): boolean { + const stmt = this.db.prepare(` + DELETE FROM cached_objects + WHERE id = ? AND object_type = ? + `); + const result = stmt.run(id, typeName); + + // Also delete related relations + this.deleteRelationsForObject(id); + + return result.changes > 0; + } + + /** + * Clear all objects of a specific type + */ + clearObjectType(typeName: CMDBObjectTypeName): number { + // First get all IDs to delete relations + const idsStmt = this.db.prepare(` + SELECT id FROM cached_objects WHERE object_type = ? + `); + const ids = idsStmt.all(typeName) as { id: string }[]; + + // Delete relations + for (const { id } of ids) { + this.deleteRelationsForObject(id); + } + + // Delete objects + const stmt = this.db.prepare(` + DELETE FROM cached_objects WHERE object_type = ? + `); + const result = stmt.run(typeName); + + logger.info(`CacheStore: Cleared ${result.changes} ${typeName} objects`); + return result.changes; + } + + /** + * Clear entire cache + */ + clearAll(): void { + this.db.exec('DELETE FROM cached_objects'); + this.db.exec('DELETE FROM object_relations'); + logger.info('CacheStore: Cleared all cached data'); + } + + // ========================================================================== + // Relation Operations + // ========================================================================== + + /** + * Store a relation between two objects + */ + upsertRelation( + sourceId: string, + targetId: string, + attributeName: string, + sourceType: string, + targetType: string + ): void { + const stmt = this.db.prepare(` + INSERT INTO object_relations (source_id, target_id, attribute_name, source_type, target_type) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(source_id, target_id, attribute_name) DO UPDATE SET + source_type = excluded.source_type, + target_type = excluded.target_type + `); + + stmt.run(sourceId, targetId, attributeName, sourceType, targetType); + } + + /** + * Batch upsert relations + */ + batchUpsertRelations(relations: Array<{ + sourceId: string; + targetId: string; + attributeName: string; + sourceType: string; + targetType: string; + }>): void { + if (relations.length === 0) return; + + const stmt = this.db.prepare(` + INSERT INTO object_relations (source_id, target_id, attribute_name, source_type, target_type) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(source_id, target_id, attribute_name) DO UPDATE SET + source_type = excluded.source_type, + target_type = excluded.target_type + `); + + const batchInsert = this.db.transaction((rels: typeof relations) => { + for (const rel of rels) { + stmt.run(rel.sourceId, rel.targetId, rel.attributeName, rel.sourceType, rel.targetType); + } + }); + + batchInsert(relations); + logger.debug(`CacheStore: Batch upserted ${relations.length} relations`); + } + + /** + * Get related objects (outbound references from an object) + */ + getRelatedObjects( + sourceId: string, + targetTypeName: CMDBObjectTypeName, + attributeName?: string + ): T[] { + let query = ` + SELECT co.data FROM cached_objects co + JOIN object_relations rel ON co.id = rel.target_id + WHERE rel.source_id = ? AND co.object_type = ? + `; + const params: (string | undefined)[] = [sourceId, targetTypeName]; + + if (attributeName) { + query += ' AND rel.attribute_name = ?'; + params.push(attributeName); + } + + const stmt = this.db.prepare(query); + const rows = stmt.all(...params) as { data: string }[]; + + return rows.map(row => { + try { + return JSON.parse(row.data) as T; + } catch { + return null; + } + }).filter((obj): obj is T => obj !== null); + } + + /** + * Get objects that reference the given object (inbound references) + */ + getReferencingObjects( + targetId: string, + sourceTypeName: CMDBObjectTypeName, + attributeName?: string + ): T[] { + let query = ` + SELECT co.data FROM cached_objects co + JOIN object_relations rel ON co.id = rel.source_id + WHERE rel.target_id = ? AND co.object_type = ? + `; + const params: (string | undefined)[] = [targetId, sourceTypeName]; + + if (attributeName) { + query += ' AND rel.attribute_name = ?'; + params.push(attributeName); + } + + const stmt = this.db.prepare(query); + const rows = stmt.all(...params) as { data: string }[]; + + return rows.map(row => { + try { + return JSON.parse(row.data) as T; + } catch { + return null; + } + }).filter((obj): obj is T => obj !== null); + } + + /** + * Delete all relations for an object + */ + deleteRelationsForObject(objectId: string): void { + const stmt = this.db.prepare(` + DELETE FROM object_relations + WHERE source_id = ? OR target_id = ? + `); + stmt.run(objectId, objectId); + } + + /** + * Extract and store relations from an object based on its type schema + */ + extractAndStoreRelations(typeName: CMDBObjectTypeName, object: T): void { + const refAttributes = getReferenceAttributes(typeName); + const relations: Array<{ + sourceId: string; + targetId: string; + attributeName: string; + sourceType: string; + targetType: string; + }> = []; + + for (const attrDef of refAttributes) { + const value = (object as unknown as Record)[attrDef.fieldName]; + + if (!value) continue; + + const targetType = attrDef.referenceTypeName || 'Unknown'; + + if (attrDef.isMultiple && Array.isArray(value)) { + for (const ref of value as ObjectReference[]) { + if (ref?.objectId) { + relations.push({ + sourceId: object.id, + targetId: ref.objectId, + attributeName: attrDef.name, + sourceType: typeName, + targetType, + }); + } + } + } else if (!attrDef.isMultiple) { + const ref = value as ObjectReference; + if (ref?.objectId) { + relations.push({ + sourceId: object.id, + targetId: ref.objectId, + attributeName: attrDef.name, + sourceType: typeName, + targetType, + }); + } + } + } + + if (relations.length > 0) { + this.batchUpsertRelations(relations); + } + } + + // ========================================================================== + // Sync Metadata Operations + // ========================================================================== + + /** + * Get sync metadata value + */ + getSyncMetadata(key: string): string | null { + const stmt = this.db.prepare(` + SELECT value FROM sync_metadata WHERE key = ? + `); + const row = stmt.get(key) as { value: string } | undefined; + return row?.value || null; + } + + /** + * Set sync metadata value + */ + setSyncMetadata(key: string, value: string): void { + const stmt = this.db.prepare(` + INSERT INTO sync_metadata (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at + `); + stmt.run(key, value, new Date().toISOString()); + } + + /** + * Delete sync metadata + */ + deleteSyncMetadata(key: string): void { + const stmt = this.db.prepare(` + DELETE FROM sync_metadata WHERE key = ? + `); + stmt.run(key); + } + + // ========================================================================== + // Statistics + // ========================================================================== + + /** + * Get cache statistics + */ + getStats(): CacheStats { + // Count by type + const typeCountStmt = this.db.prepare(` + SELECT object_type, COUNT(*) as count + FROM cached_objects + GROUP BY object_type + `); + const typeCounts = typeCountStmt.all() as { object_type: string; count: number }[]; + + const objectsByType: Record = {}; + let totalObjects = 0; + for (const { object_type, count } of typeCounts) { + objectsByType[object_type] = count; + totalObjects += count; + } + + // Count relations + const relCountStmt = this.db.prepare(` + SELECT COUNT(*) as count FROM object_relations + `); + const relCount = (relCountStmt.get() as { count: number }).count; + + // Get sync metadata + const lastFullSync = this.getSyncMetadata('lastFullSync'); + const lastIncrementalSync = this.getSyncMetadata('lastIncrementalSync'); + + // Check if cache is warm (has Application Components) + const isWarm = (objectsByType['ApplicationComponent'] || 0) > 0; + + // Get database file size + let dbSizeBytes = 0; + try { + const stats = fs.statSync(CACHE_DB_PATH); + dbSizeBytes = stats.size; + } catch { + // Ignore + } + + return { + totalObjects, + objectsByType, + totalRelations: relCount, + lastFullSync, + lastIncrementalSync, + isWarm, + dbSizeBytes, + }; + } + + /** + * Check if cache is warm (has data) + */ + isWarm(): boolean { + const count = this.countObjects('ApplicationComponent'); + return count > 0; + } + + /** + * Close database connection + */ + close(): void { + this.db.close(); + } +} + +// Export singleton instance +export const cacheStore = new CacheStore(); + diff --git a/backend/src/services/claude.ts b/backend/src/services/claude.ts index e280444..58d96d1 100644 --- a/backend/src/services/claude.ts +++ b/backend/src/services/claude.ts @@ -372,7 +372,7 @@ async function formatApplicationFunctionsForPrompt( return sections.join('\n\n'); } -// Format reference objects for prompt (Application Type, Dynamics Factor, etc.) +// Format reference objects for prompt (Application Type, etc.) function formatReferenceObjectsForPrompt( objects: ReferenceValue[], useSummary: boolean = false @@ -391,10 +391,30 @@ function formatReferenceObjectsForPrompt( .join('\n'); } +// Format factors (Dynamics/Complexity) with description for AI prompt +function formatFactorsForPrompt(objects: ReferenceValue[]): string { + if (objects.length === 0) { + return 'Geen factoren beschikbaar.'; + } + + return objects + .map((obj) => { + const parts: string[] = [` - ${obj.key}: ${obj.name}`]; + if (obj.factor !== undefined) { + parts[0] += ` (factor: ${obj.factor})`; + } + if (obj.description) { + parts.push(` ${obj.description}`); + } + return parts.join('\n'); + }) + .join('\n'); +} + // Format reference objects with emphasis on exact name (for fields where AI must use exact name) function formatReferenceObjectsWithExactNames( objects: ReferenceValue[], - useSummary: boolean = false + useDescription: boolean = false ): string { if (objects.length === 0) { return 'Geen objecten beschikbaar.'; @@ -402,9 +422,7 @@ function formatReferenceObjectsWithExactNames( return objects .map((obj) => { - const displayText = useSummary && obj.summary - ? obj.summary - : obj.description || ''; + const displayText = useDescription && obj.description ? obj.description : ''; // Emphasize the exact name that should be used return ` - **"${obj.name}"**${displayText ? ` - ${displayText}` : ''}`; }) @@ -892,8 +910,8 @@ class AIService { applicationFunctionCategories ); const applicationTypesFormatted = formatReferenceObjectsForPrompt(applicationTypes, false); - const dynamicsFactorsFormatted = formatReferenceObjectsForPrompt(dynamicsFactors, true); - const complexityFactorsFormatted = formatReferenceObjectsForPrompt(complexityFactors, true); + const dynamicsFactorsFormatted = formatFactorsForPrompt(dynamicsFactors); + const complexityFactorsFormatted = formatFactorsForPrompt(complexityFactors); const applicationManagementHostingFormatted = formatReferenceObjectsWithExactNames(applicationManagementHostingOptions, true); const businessImpactAnalysesFormatted = formatBusinessImpactAnalysesForPrompt(businessImpactAnalyses); const businessImpactIndicatorsFormatted = formatBusinessImpactIndicatorsForPrompt(businessImpactAnalyses); @@ -1133,8 +1151,8 @@ class AIService { applicationFunctionCategories ); const applicationTypesFormatted = formatReferenceObjectsForPrompt(applicationTypes, false); - const dynamicsFactorsFormatted = formatReferenceObjectsForPrompt(dynamicsFactors, true); - const complexityFactorsFormatted = formatReferenceObjectsForPrompt(complexityFactors, true); + const dynamicsFactorsFormatted = formatFactorsForPrompt(dynamicsFactors); + const complexityFactorsFormatted = formatFactorsForPrompt(complexityFactors); const applicationManagementHostingFormatted = formatReferenceObjectsWithExactNames(applicationManagementHostingOptions, true); const businessImpactAnalysesFormatted = formatBusinessImpactAnalysesForPrompt(businessImpactAnalyses); const businessImpactIndicatorsFormatted = formatBusinessImpactIndicatorsForPrompt(businessImpactAnalyses); diff --git a/backend/src/services/cmdbService.ts b/backend/src/services/cmdbService.ts new file mode 100644 index 0000000..fc3e764 --- /dev/null +++ b/backend/src/services/cmdbService.ts @@ -0,0 +1,445 @@ +/** + * CMDBService - Universal schema-driven CMDB service + * + * Provides a unified interface for all CMDB operations: + * - Reads from cache for fast access + * - Write-through to Jira with conflict detection + * - Schema-driven parsing and updates + */ + +import { logger } from './logger.js'; +import { cacheStore, type CacheStats } from './cacheStore.js'; +import { jiraAssetsClient, type JiraUpdatePayload, JiraObjectNotFoundError } from './jiraAssetsClient.js'; +import { conflictResolver, type ConflictCheckResult } from './conflictResolver.js'; +import { OBJECT_TYPES, getAttributeDefinition } from '../generated/jira-schema.js'; +import type { CMDBObject, CMDBObjectTypeName, ObjectReference } from '../generated/jira-types.js'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface GetObjectOptions { + /** Force refresh from Jira (bypasses cache) */ + forceRefresh?: boolean; +} + +export interface UpdateResult { + success: boolean; + data?: CMDBObject; + conflict?: ConflictCheckResult; + error?: string; +} + +export interface SearchOptions { + limit?: number; + offset?: number; + searchTerm?: string; +} + +// ============================================================================= +// Service Implementation +// ============================================================================= + +class CMDBService { + // ========================================================================== + // Read Operations + // ========================================================================== + + /** + * Get a single object by ID + * By default reads from cache; use forceRefresh to fetch from Jira + */ + async getObject( + typeName: CMDBObjectTypeName, + id: string, + options?: GetObjectOptions + ): Promise { + // Force refresh: always fetch from Jira + if (options?.forceRefresh) { + return this.fetchAndCacheObject(typeName, id); + } + + // Try cache first + const cached = cacheStore.getObject(typeName, id); + if (cached) { + return cached; + } + + // Cache miss: fetch from Jira + return this.fetchAndCacheObject(typeName, id); + } + + /** + * Get a single object by object key (e.g., "ICMT-123") + */ + async getObjectByKey( + typeName: CMDBObjectTypeName, + objectKey: string, + options?: GetObjectOptions + ): Promise { + // Force refresh: search Jira by key + if (options?.forceRefresh) { + const typeDef = OBJECT_TYPES[typeName]; + if (!typeDef) return null; + + const iql = `objectType = "${typeDef.name}" AND Key = "${objectKey}"`; + const result = await jiraAssetsClient.searchObjects(iql, 1, 1); + + if (result.objects.length === 0) return null; + + const parsed = jiraAssetsClient.parseObject(result.objects[0]); + if (parsed) { + cacheStore.upsertObject(typeName, parsed); + cacheStore.extractAndStoreRelations(typeName, parsed); + } + return parsed; + } + + // Try cache first + const cached = cacheStore.getObjectByKey(typeName, objectKey); + if (cached) { + return cached; + } + + // Cache miss: search Jira + return this.getObjectByKey(typeName, objectKey, { forceRefresh: true }); + } + + /** + * Fetch a single object from Jira and update cache + * If the object was deleted from Jira (404), it will be removed from the local cache + */ + private async fetchAndCacheObject( + typeName: CMDBObjectTypeName, + id: string + ): Promise { + try { + const jiraObj = await jiraAssetsClient.getObject(id); + if (!jiraObj) return null; + + const parsed = jiraAssetsClient.parseObject(jiraObj); + if (parsed) { + cacheStore.upsertObject(typeName, parsed); + cacheStore.extractAndStoreRelations(typeName, parsed); + } + return parsed; + } catch (error) { + // If object was deleted from Jira, remove it from our cache + if (error instanceof JiraObjectNotFoundError) { + const deleted = cacheStore.deleteObject(typeName, id); + if (deleted) { + logger.info(`CMDBService: Removed deleted object ${typeName}/${id} from cache`); + } + return null; + } + // Re-throw other errors + throw error; + } + } + + /** + * Get all objects of a type from cache + */ + async getObjects( + typeName: CMDBObjectTypeName, + options?: SearchOptions + ): Promise { + if (options?.searchTerm) { + return cacheStore.searchByLabel(typeName, options.searchTerm, { + limit: options.limit, + offset: options.offset, + }); + } + + return cacheStore.getObjects(typeName, { + limit: options?.limit, + offset: options?.offset, + }); + } + + /** + * Count objects of a type in cache + */ + countObjects(typeName: CMDBObjectTypeName): number { + return cacheStore.countObjects(typeName); + } + + /** + * Search across all object types + */ + async searchAllTypes(searchTerm: string, options?: { limit?: number }): Promise { + return cacheStore.searchAllTypes(searchTerm, { limit: options?.limit }); + } + + /** + * Get related objects (outbound references) + */ + async getRelatedObjects( + sourceId: string, + attributeName: string, + targetTypeName: CMDBObjectTypeName + ): Promise { + return cacheStore.getRelatedObjects(sourceId, targetTypeName, attributeName); + } + + /** + * Get objects that reference the given object (inbound references) + */ + async getReferencingObjects( + targetId: string, + sourceTypeName: CMDBObjectTypeName, + attributeName?: string + ): Promise { + return cacheStore.getReferencingObjects(targetId, sourceTypeName, attributeName); + } + + // ========================================================================== + // Write Operations + // ========================================================================== + + /** + * Update an object with conflict detection + * + * @param typeName - The object type + * @param id - The object ID + * @param updates - Field updates (only changed fields) + * @param originalUpdatedAt - The _jiraUpdatedAt from when the object was loaded for editing + */ + async updateObject( + typeName: CMDBObjectTypeName, + id: string, + updates: Record, + originalUpdatedAt: string + ): Promise { + try { + // 1. Check for conflicts + const conflictResult = await conflictResolver.checkConflict( + typeName, + id, + originalUpdatedAt, + updates + ); + + if (conflictResult.hasConflict && conflictResult.conflicts && conflictResult.conflicts.length > 0) { + return { + success: false, + conflict: conflictResult, + }; + } + + // 2. Build Jira update payload + const payload = this.buildUpdatePayload(typeName, updates); + + if (payload.attributes.length === 0) { + logger.warn(`CMDBService: No attributes to update for ${typeName} ${id}`); + return { success: true }; + } + + // 3. Send update to Jira + const success = await jiraAssetsClient.updateObject(id, payload); + + if (!success) { + return { + success: false, + error: 'Failed to update object in Jira', + }; + } + + // 4. Fetch fresh data and update cache + const freshData = await this.fetchAndCacheObject(typeName, id); + + logger.info(`CMDBService: Updated ${typeName} ${id}`); + + return { + success: true, + data: freshData || undefined, + }; + } catch (error) { + logger.error(`CMDBService: Update failed for ${typeName} ${id}`, error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Force update without conflict check (use with caution) + */ + async forceUpdateObject( + typeName: CMDBObjectTypeName, + id: string, + updates: Record + ): Promise { + try { + const payload = this.buildUpdatePayload(typeName, updates); + + if (payload.attributes.length === 0) { + return { success: true }; + } + + const success = await jiraAssetsClient.updateObject(id, payload); + + if (!success) { + return { + success: false, + error: 'Failed to update object in Jira', + }; + } + + const freshData = await this.fetchAndCacheObject(typeName, id); + + return { + success: true, + data: freshData || undefined, + }; + } catch (error) { + logger.error(`CMDBService: Force update failed for ${typeName} ${id}`, error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Build Jira update payload from field updates + */ + private buildUpdatePayload( + typeName: CMDBObjectTypeName, + updates: Record + ): JiraUpdatePayload { + const attributes: JiraUpdatePayload['attributes'] = []; + + logger.debug(`CMDBService.buildUpdatePayload: Building payload for ${typeName}`, { + updateKeys: Object.keys(updates), + updates: JSON.stringify(updates, null, 2) + }); + + for (const [fieldName, value] of Object.entries(updates)) { + const attrDef = getAttributeDefinition(typeName, fieldName); + + if (!attrDef) { + logger.warn(`CMDBService: Unknown attribute ${fieldName} for ${typeName}`); + continue; + } + + if (!attrDef.isEditable) { + logger.warn(`CMDBService: Attribute ${fieldName} is not editable`); + continue; + } + + const attrValues = this.buildAttributeValues(value, attrDef); + + logger.debug(`CMDBService.buildUpdatePayload: Attribute ${fieldName} (jiraId: ${attrDef.jiraId}, type: ${attrDef.type}, isMultiple: ${attrDef.isMultiple})`, { + inputValue: JSON.stringify(value), + outputValues: JSON.stringify(attrValues) + }); + + attributes.push({ + objectTypeAttributeId: attrDef.jiraId, + objectAttributeValues: attrValues, + }); + } + + logger.debug(`CMDBService.buildUpdatePayload: Final payload`, { + attributeCount: attributes.length, + payload: JSON.stringify({ attributes }, null, 2) + }); + + return { attributes }; + } + + /** + * Build attribute values for Jira API + */ + private buildAttributeValues( + value: unknown, + attrDef: { type: string; isMultiple: boolean } + ): Array<{ value?: string }> { + // Null/undefined = clear the field + if (value === null || value === undefined) { + return []; + } + + // Reference type + if (attrDef.type === 'reference') { + if (attrDef.isMultiple && Array.isArray(value)) { + return (value as ObjectReference[]).map(ref => ({ + value: ref.objectKey, + })); + } else if (!attrDef.isMultiple) { + const ref = value as ObjectReference; + return [{ value: ref.objectKey }]; + } + return []; + } + + // Boolean + if (attrDef.type === 'boolean') { + return [{ value: value ? 'true' : 'false' }]; + } + + // Number types + if (attrDef.type === 'integer' || attrDef.type === 'float') { + return [{ value: String(value) }]; + } + + // String types + return [{ value: String(value) }]; + } + + // ========================================================================== + // Cache Management + // ========================================================================== + + /** + * Get cache statistics + */ + getCacheStats(): CacheStats { + return cacheStore.getStats(); + } + + /** + * Check if cache has data + */ + isCacheWarm(): boolean { + return cacheStore.isWarm(); + } + + /** + * Clear cache for a specific type + */ + clearCacheForType(typeName: CMDBObjectTypeName): void { + cacheStore.clearObjectType(typeName); + } + + /** + * Clear entire cache + */ + clearCache(): void { + cacheStore.clearAll(); + } + + // ========================================================================== + // User Token Management (for OAuth) + // ========================================================================== + + /** + * Set user token for current request + */ + setUserToken(token: string | null): void { + jiraAssetsClient.setRequestToken(token); + } + + /** + * Clear user token + */ + clearUserToken(): void { + jiraAssetsClient.clearRequestToken(); + } +} + +// Export singleton instance +export const cmdbService = new CMDBService(); + diff --git a/backend/src/services/conflictResolver.ts b/backend/src/services/conflictResolver.ts new file mode 100644 index 0000000..6259a55 --- /dev/null +++ b/backend/src/services/conflictResolver.ts @@ -0,0 +1,254 @@ +/** + * ConflictResolver - Detects and reports conflicts when updating CMDB objects + * + * Implements optimistic locking by comparing timestamps and checking + * for field-level conflicts. + */ + +import { logger } from './logger.js'; +import { jiraAssetsClient, JiraObjectNotFoundError } from './jiraAssetsClient.js'; +import { OBJECT_TYPES, getAttributeDefinition } from '../generated/jira-schema.js'; +import type { CMDBObjectTypeName } from '../generated/jira-types.js'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface FieldConflict { + field: string; + fieldName: string; + proposedValue: unknown; + jiraValue: unknown; +} + +export interface ConflictCheckResult { + hasConflict: boolean; + conflicts?: FieldConflict[]; + jiraUpdatedAt?: string; + warning?: string; + canMerge?: boolean; +} + +// ============================================================================= +// Conflict Resolver Implementation +// ============================================================================= + +class ConflictResolver { + /** + * Check for conflicts before updating an object + * + * @param typeName - The object type + * @param objectId - The object ID + * @param originalUpdatedAt - The _jiraUpdatedAt from when the user started editing + * @param proposedChanges - The changes the user wants to make + */ + async checkConflict( + typeName: CMDBObjectTypeName, + objectId: string, + originalUpdatedAt: string, + proposedChanges: Record + ): Promise { + try { + // 1. Fetch current state from Jira + let jiraObj; + try { + jiraObj = await jiraAssetsClient.getObject(objectId); + } catch (error) { + if (error instanceof JiraObjectNotFoundError) { + return { + hasConflict: true, + warning: 'Object has been deleted from Jira', + }; + } + throw error; + } + + if (!jiraObj) { + return { + hasConflict: true, + warning: 'Object not found in Jira', + }; + } + + const currentUpdatedAt = jiraObj.updated || ''; + + // 2. Compare timestamps + if (currentUpdatedAt === originalUpdatedAt) { + // No changes since user started editing - safe to update + return { hasConflict: false }; + } + + // 3. Timestamp differs - check field-level conflicts + logger.info(`ConflictResolver: Timestamp mismatch for ${objectId}. Original: ${originalUpdatedAt}, Current: ${currentUpdatedAt}`); + + const typeDef = OBJECT_TYPES[typeName]; + if (!typeDef) { + return { + hasConflict: true, + warning: `Unknown object type: ${typeName}`, + }; + } + + const conflictingFields: FieldConflict[] = []; + + for (const [fieldName, proposedValue] of Object.entries(proposedChanges)) { + const attrDef = getAttributeDefinition(typeName, fieldName); + if (!attrDef) continue; + + // Find the attribute in Jira response + const jiraAttr = jiraObj.attributes.find( + a => a.objectTypeAttributeId === attrDef.jiraId || + a.objectTypeAttribute?.name === attrDef.name + ); + + // Extract current value from Jira + const jiraValue = this.extractAttributeValue(jiraAttr, attrDef); + + // Compare values + if (!this.valuesEqual(proposedValue, jiraValue, attrDef.type)) { + conflictingFields.push({ + field: attrDef.name, + fieldName, + proposedValue, + jiraValue, + }); + } + } + + if (conflictingFields.length === 0) { + // Object was updated but not on the same fields - safe to merge + return { + hasConflict: false, + warning: 'Object was updated but no field conflicts detected', + jiraUpdatedAt: currentUpdatedAt, + canMerge: true, + }; + } + + // Real conflicts exist + return { + hasConflict: true, + conflicts: conflictingFields, + jiraUpdatedAt: currentUpdatedAt, + canMerge: false, + }; + } catch (error) { + logger.error(`ConflictResolver: Error checking conflict for ${objectId}`, error); + return { + hasConflict: true, + warning: 'Error checking for conflicts', + }; + } + } + + /** + * Extract a typed value from a Jira attribute + */ + private extractAttributeValue( + jiraAttr: { objectAttributeValues: Array<{ + value?: string; + displayValue?: string; + referencedObject?: { id: number; objectKey: string; label: string }; + }> } | undefined, + attrDef: { type: string; isMultiple: boolean } + ): unknown { + if (!jiraAttr?.objectAttributeValues?.length) { + return attrDef.isMultiple ? [] : null; + } + + const values = jiraAttr.objectAttributeValues; + + switch (attrDef.type) { + case 'reference': { + const refs = values + .filter(v => v.referencedObject) + .map(v => ({ + objectId: v.referencedObject!.id.toString(), + objectKey: v.referencedObject!.objectKey, + label: v.referencedObject!.label, + })); + return attrDef.isMultiple ? refs : refs[0] || null; + } + + case 'boolean': { + const val = values[0]?.value; + return val === 'true' || val === 'Ja'; + } + + case 'integer': { + const val = values[0]?.value; + return val ? parseInt(val, 10) : null; + } + + case 'float': { + const val = values[0]?.value; + return val ? parseFloat(val) : null; + } + + default: + return values[0]?.displayValue ?? values[0]?.value ?? null; + } + } + + /** + * Compare two values for equality + */ + private valuesEqual( + proposed: unknown, + jira: unknown, + type: string + ): boolean { + // Both null/undefined + if (proposed == null && jira == null) { + return true; + } + + // One is null + if (proposed == null || jira == null) { + return false; + } + + // Reference type + if (type === 'reference') { + return this.referencesEqual(proposed, jira); + } + + // Arrays + if (Array.isArray(proposed) && Array.isArray(jira)) { + if (proposed.length !== jira.length) return false; + // For reference arrays, compare by objectId + if (type === 'reference') { + const proposedIds = new Set((proposed as Array<{ objectId?: string }>).map(r => r.objectId)); + const jiraIds = new Set((jira as Array<{ objectId?: string }>).map(r => r.objectId)); + if (proposedIds.size !== jiraIds.size) return false; + for (const id of proposedIds) { + if (!jiraIds.has(id)) return false; + } + return true; + } + return JSON.stringify(proposed) === JSON.stringify(jira); + } + + // Primitives + return proposed === jira; + } + + /** + * Compare reference values + */ + private referencesEqual(proposed: unknown, jira: unknown): boolean { + // Compare by objectId or objectKey + const propRef = proposed as { objectId?: string; objectKey?: string } | null; + const jiraRef = jira as { objectId?: string; objectKey?: string } | null; + + if (!propRef && !jiraRef) return true; + if (!propRef || !jiraRef) return false; + + return propRef.objectId === jiraRef.objectId || + propRef.objectKey === jiraRef.objectKey; + } +} + +// Export singleton instance +export const conflictResolver = new ConflictResolver(); + diff --git a/backend/src/services/dataService.ts b/backend/src/services/dataService.ts index 6d4e6c2..302ac62 100644 --- a/backend/src/services/dataService.ts +++ b/backend/src/services/dataService.ts @@ -1,45 +1,661 @@ +/** + * DataService - Main entry point for application data access + * + * Routes requests to either: + * - CMDBService (using local cache) for real Jira data + * - MockDataService for development without Jira + */ + import { config } from '../config/env.js'; +import { cmdbService, type UpdateResult } from './cmdbService.js'; +import { cacheStore, type CacheStats } from './cacheStore.js'; +import { jiraAssetsClient } from './jiraAssetsClient.js'; import { jiraAssetsService } from './jiraAssets.js'; import { mockDataService } from './mockData.js'; import { logger } from './logger.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, - ApplicationUpdateRequest, ReferenceValue, SearchFilters, SearchResult, TeamDashboardData, + TeamDashboardTeam, + TeamDashboardSubteam, + PlatformWithWorkloads, } from '../types/index.js'; +import { calculateRequiredEffortWithMinMax } from './effortCalculation.js'; // Determine if we should use real Jira Assets or mock data const useJiraAssets = !!(config.jiraPat && config.jiraSchemaId); if (useJiraAssets) { - logger.info('Using Jira Assets API for data'); + logger.info('DataService: Using CMDB cache layer with Jira Assets API'); } else { - logger.info('Using mock data (Jira credentials not configured)'); + logger.info('DataService: Using mock data (Jira credentials not configured)'); } +// ============================================================================= +// Reference Cache (for enriching IDs to ObjectReferences) +// ============================================================================= + +// Cache maps for quick ID -> ObjectReference lookups +const referenceCache: Map> = new Map(); + +/** + * Initialize or refresh reference cache for a given object type + */ +async function ensureReferenceCache(typeName: CMDBObjectTypeName): Promise> { + if (!referenceCache.has(typeName)) { + const objects = await cmdbService.getObjects(typeName); + const cache = new Map(); + for (const obj of objects) { + cache.set(obj.id, obj); + } + referenceCache.set(typeName, cache); + } + return referenceCache.get(typeName) as Map; +} + +/** + * Lookup a reference object by ID and return as ReferenceValue + */ +async function lookupReference( + typeName: CMDBObjectTypeName, + id: number | null +): Promise { + if (id === null) return null; + const cache = await ensureReferenceCache(typeName); + const obj = cache.get(String(id)); + if (!obj) return null; + return { + objectId: obj.id, + key: obj.objectKey, + name: obj.label, + }; +} + +/** + * Lookup multiple reference objects by IDs + */ +async function lookupReferences( + typeName: CMDBObjectTypeName, + ids: number[] | null | undefined +): Promise { + if (!ids || ids.length === 0) return []; + const cache = await ensureReferenceCache(typeName); + return ids + .map(id => cache.get(String(id))) + .filter((obj): obj is T => obj !== undefined) + .map(obj => ({ + objectId: obj.id, + key: obj.objectKey, + name: obj.label, + })); +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Convert ObjectReference to ReferenceValue format used by frontend + */ +function toReferenceValue(ref: ObjectReference | null | undefined): ReferenceValue | null { + if (!ref) return null; + 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, + name: ref.label, + })); +} + +/** + * Extract label from ObjectReference or return string as-is + * Handles fields that may be ObjectReference or plain string + */ +function extractLabel(value: unknown): string | null { + if (!value) return null; + if (typeof value === 'string') return value; + if (typeof value === 'object' && value !== null) { + const obj = value as Record; + // ObjectReference format + if (obj.label) return String(obj.label); + // User format from Jira + if (obj.displayName) return String(obj.displayName); + if (obj.name) return String(obj.name); + } + return null; +} + +/** + * Extract display value from user field or other complex types + * Handles arrays and objects with displayValue property + */ +function extractDisplayValue(value: unknown): string | null { + if (!value) return null; + if (typeof value === 'string') return value; + if (typeof value === 'boolean') return null; // Ignore boolean "false" values + if (Array.isArray(value)) { + return value.map(v => extractLabel(v)).filter(Boolean).join(', ') || null; + } + if (typeof value === 'object' && value !== null) { + const obj = value as Record; + // User format from Jira + if (obj.displayValue) return String(obj.displayValue); + if (obj.displayName) return String(obj.displayName); + if (obj.name) return String(obj.name); + if (obj.label) return String(obj.label); + } + return null; +} + +/** + * Convert cached ApplicationComponent to ApplicationDetails format + * References are now stored as ObjectReference objects directly (not IDs) + */ +async function toApplicationDetails(app: ApplicationComponent): Promise { + // Ensure factor caches are loaded for factor value lookup + await ensureFactorCaches(); + + // Convert ObjectReference to ReferenceValue format + const governanceModel = toReferenceValue(app.ictGovernanceModel); + // Note: applicationManagementSubteam and applicationManagementTeam are not in the generated schema + // They are only available when fetching directly from Jira API (via jiraAssetsClient) + const applicationSubteam = toReferenceValue((app as any).applicationManagementSubteam); + const applicationTeam = toReferenceValue((app as any).applicationManagementTeam); + const applicationType = toReferenceValue(app.applicationManagementApplicationType); + const applicationManagementHosting = toReferenceValue(app.applicationManagementHosting); + const applicationManagementTAM = toReferenceValue(app.applicationManagementTAM); + const hostingType = toReferenceValue(app.applicationComponentHostingType); + const businessImpactAnalyse = toReferenceValue(app.businessImpactAnalyse); + const platform = toReferenceValue(app.platform); + const organisation = toReferenceValue(app.organisation); + const businessImportance = toReferenceValue(app.businessImportance); + + // Look up factor values from cached factor objects (same as toMinimalDetailsForEffort) + 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, + }; + } + + // Convert array of ObjectReferences to ReferenceValue[] + const applicationFunctions = toReferenceValues(app.applicationFunction); + + 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), + + // 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, + }; +} + +// Pre-loaded factor caches for synchronous access +let dynamicsFactorCache: Map | null = null; +let complexityFactorCache: Map | null = null; +let numberOfUsersCache: Map | null = null; + +/** + * Initialize factor caches for effort calculation + * Must be called before using toMinimalDetailsForEffort or toApplicationListItem + */ +async function ensureFactorCaches(): Promise { + if (!dynamicsFactorCache) { + const items = await cmdbService.getObjects('ApplicationManagementDynamicsFactor'); + dynamicsFactorCache = new Map(items.map(item => [item.id, item])); + } + if (!complexityFactorCache) { + const items = await cmdbService.getObjects('ApplicationManagementComplexityFactor'); + complexityFactorCache = new Map(items.map(item => [item.id, item])); + } + if (!numberOfUsersCache) { + const items = await cmdbService.getObjects('ApplicationManagementNumberOfUsers'); + numberOfUsersCache = new Map(items.map(item => [item.id, item])); + } +} + +/** + * Clear factor caches (call when cache is refreshed) + */ +function clearFactorCaches(): void { + dynamicsFactorCache = null; + complexityFactorCache = null; + numberOfUsersCache = null; +} + +/** + * Create a minimal ApplicationDetails object from ApplicationComponent for effort calculation + * This avoids the overhead of toApplicationDetails while providing enough data for effort calculation + * Note: ensureFactorCaches() must be called before using this function + */ +function toMinimalDetailsForEffort(app: ApplicationComponent): ApplicationDetails { + const governanceModel = toReferenceValue(app.ictGovernanceModel); + const applicationType = toReferenceValue(app.applicationManagementApplicationType); + const businessImpactAnalyse = toReferenceValue(app.businessImpactAnalyse); + const applicationManagementHosting = 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, + }; + } + + 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) + */ +function toApplicationListItem(app: ApplicationComponent): ApplicationListItem { + // Use direct ObjectReference conversion instead of lookups + const governanceModel = toReferenceValue(app.ictGovernanceModel); + const dynamicsFactor = toReferenceValue(app.applicationManagementDynamicsFactor); + const complexityFactor = toReferenceValue(app.applicationManagementComplexityFactor); + // Note: Team/Subteam fields are not in generated schema, use type assertion + const applicationSubteam = toReferenceValue((app as any).applicationManagementSubteam); + const applicationTeam = toReferenceValue((app as any).applicationManagementTeam); + const applicationType = toReferenceValue(app.applicationManagementApplicationType); + const platform = toReferenceValue(app.platform); + const applicationFunctions = toReferenceValues(app.applicationFunction); + const applicationManagementHosting = toReferenceValue(app.applicationManagementHosting); + const applicationManagementTAM = toReferenceValue(app.applicationManagementTAM); + + // Calculate effort using minimal details + const minimalDetails = toMinimalDetailsForEffort(app); + const effortResult = calculateRequiredEffortWithMinMax(minimalDetails); + + return { + id: app.id, + key: app.objectKey, + name: app.label, + status: (app.status || 'In Production') as ApplicationStatus, + applicationFunctions, + governanceModel, + dynamicsFactor, + complexityFactor, + applicationSubteam, + applicationTeam, + applicationType, + platform, + applicationManagementHosting, + applicationManagementTAM, + requiredEffortApplicationManagement: effortResult.finalEffort, + minFTE: effortResult.minFTE, + maxFTE: effortResult.maxFTE, + overrideFTE: app.applicationManagementOverrideFTE ?? null, + }; +} + +// ============================================================================= +// Data Service +// ============================================================================= + export const dataService = { + /** + * Search applications + */ async searchApplications( filters: SearchFilters, page: number = 1, pageSize: number = 25 ): Promise { - if (useJiraAssets) { - return jiraAssetsService.searchApplications(filters, page, pageSize); + if (!useJiraAssets) { + return mockDataService.searchApplications(filters, page, pageSize); } - return mockDataService.searchApplications(filters, page, pageSize); + + // Get all applications from cache + let apps = await cmdbService.getObjects('ApplicationComponent'); + + // Apply filters locally + if (filters.searchText) { + const search = filters.searchText.toLowerCase(); + apps = apps.filter(app => + app.label.toLowerCase().includes(search) || + app.objectKey.toLowerCase().includes(search) || + app.searchReference?.toLowerCase().includes(search) || + app.description?.toLowerCase().includes(search) + ); + } + + if (filters.statuses && filters.statuses.length > 0) { + apps = apps.filter(app => filters.statuses!.includes(app.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 (synchronous now) + const applications = 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 { - if (useJiraAssets) { - return jiraAssetsService.getApplicationById(id); + if (!useJiraAssets) { + return mockDataService.getApplicationById(id); } - return mockDataService.getApplicationById(id); + + const app = await cmdbService.getObject('ApplicationComponent', id); + if (!app) return null; + + return toApplicationDetails(app); }, + /** + * Get application for editing (force refresh from Jira) + */ + async getApplicationForEdit(id: string): Promise { + if (!useJiraAssets) { + return mockDataService.getApplicationById(id); + } + + const app = await cmdbService.getObject('ApplicationComponent', id, { + forceRefresh: true, + }); + if (!app) return null; + + return toApplicationDetails(app); + }, + + /** + * Update application with conflict detection + */ async updateApplication( id: string, updates: { @@ -48,160 +664,418 @@ export const dataService = { complexityFactor?: ReferenceValue; numberOfUsers?: ReferenceValue; governanceModel?: ReferenceValue; - applicationCluster?: ReferenceValue; + applicationSubteam?: ReferenceValue; + applicationTeam?: ReferenceValue; applicationType?: ReferenceValue; hostingType?: ReferenceValue; businessImpactAnalyse?: ReferenceValue; overrideFTE?: number | null; applicationManagementHosting?: string; applicationManagementTAM?: string; - } - ): Promise { + }, + originalUpdatedAt?: string + ): Promise { logger.info(`dataService.updateApplication called for ${id}`); - logger.info(`Updates from frontend: ${JSON.stringify(updates)}`); - if (useJiraAssets) { - // Convert ReferenceValues to keys for Jira update - const jiraUpdates: ApplicationUpdateRequest = { - applicationFunctions: updates.applicationFunctions?.map((f) => f.key), - dynamicsFactor: updates.dynamicsFactor?.key, - complexityFactor: updates.complexityFactor?.key, - numberOfUsers: updates.numberOfUsers?.key, - governanceModel: updates.governanceModel?.key, - applicationCluster: updates.applicationCluster?.key, - applicationType: updates.applicationType?.key, - hostingType: updates.hostingType?.key, - businessImpactAnalyse: updates.businessImpactAnalyse?.key, - overrideFTE: updates.overrideFTE, - applicationManagementHosting: updates.applicationManagementHosting, - applicationManagementTAM: updates.applicationManagementTAM, - }; - logger.info(`Converted to Jira format: ${JSON.stringify(jiraUpdates)}`); - return jiraAssetsService.updateApplication(id, jiraUpdates); + if (!useJiraAssets) { + const success = await mockDataService.updateApplication(id, updates); + return { success }; } - return mockDataService.updateApplication(id, updates); + + // Convert to CMDBService format + // IMPORTANT: For reference fields, we pass ObjectReference objects (with objectKey) + // because buildAttributeValues in cmdbService expects to extract objectKey for Jira API + const cmdbUpdates: Record = {}; + + if (updates.applicationFunctions !== undefined) { + // Store as array of ObjectReferences - cmdbService.buildAttributeValues will extract objectKeys + cmdbUpdates.applicationFunction = updates.applicationFunctions.map(f => ({ + objectId: f.objectId, + objectKey: f.key, + label: f.name, + })); + } + + // Map frontend field names to actual Jira field names + const fieldMapping: Record = { + dynamicsFactor: 'applicationManagementDynamicsFactor', + complexityFactor: 'applicationManagementComplexityFactor', + numberOfUsers: 'applicationManagementNumberOfUsers', + governanceModel: 'ictGovernanceModel', + applicationSubteam: 'applicationManagementSubteam', + applicationTeam: 'applicationManagementTeam', + applicationType: 'applicationManagementApplicationType', + hostingType: 'applicationComponentHostingType', + businessImpactAnalyse: 'businessImpactAnalyse', + }; + + for (const [frontendField, jiraField] of Object.entries(fieldMapping)) { + const value = updates[frontendField as keyof typeof updates] as ReferenceValue | undefined; + if (value !== undefined) { + // Store as ObjectReference - cmdbService.buildAttributeValues will extract objectKey + cmdbUpdates[jiraField] = value ? { + objectId: value.objectId, + objectKey: value.key, + label: value.name, + } : null; + } + } + + if (updates.overrideFTE !== undefined) { + cmdbUpdates.applicationManagementOverrideFTE = updates.overrideFTE; + } + + if (updates.applicationManagementHosting !== undefined) { + // Look up the full object to get the objectKey + const hostingItems = await cmdbService.getObjects('ApplicationManagementHosting'); + const match = hostingItems.find(h => h.objectKey === updates.applicationManagementHosting || h.label === updates.applicationManagementHosting); + cmdbUpdates.applicationManagementHosting = match ? { + objectId: match.id, + objectKey: match.objectKey, + label: match.label, + } : null; + } + + if (updates.applicationManagementTAM !== undefined) { + // Look up the full object to get the objectKey + const tamItems = await cmdbService.getObjects('ApplicationManagementTam'); + const match = tamItems.find(t => t.objectKey === updates.applicationManagementTAM || t.label === updates.applicationManagementTAM); + cmdbUpdates.applicationManagementTAM = match ? { + objectId: match.id, + objectKey: match.objectKey, + label: match.label, + } : null; + } + + // If originalUpdatedAt is provided, use conflict detection + let result: UpdateResult; + if (originalUpdatedAt) { + result = await cmdbService.updateObject('ApplicationComponent', id, cmdbUpdates, originalUpdatedAt); + } else { + // Force update without conflict check (legacy behavior) + result = await cmdbService.forceUpdateObject('ApplicationComponent', id, cmdbUpdates); + } + + // Invalidate team dashboard cache after successful update + // This ensures the dashboard shows fresh data immediately + if (result.success) { + jiraAssetsService.invalidateTeamDashboardCache(); + logger.info(`Invalidated team dashboard cache after updating application ${id}`); + } + + return result; }, + // =========================================================================== + // Reference Data (from cache) + // =========================================================================== + async getDynamicsFactors(): Promise { - if (useJiraAssets) { - return jiraAssetsService.getDynamicsFactors(); - } - return mockDataService.getDynamicsFactors(); + if (!useJiraAssets) return mockDataService.getDynamicsFactors(); + const items = await cmdbService.getObjects('ApplicationManagementDynamicsFactor'); + return items.map(item => ({ + objectId: item.id, + key: item.objectKey, + name: item.label, + summary: item.summary || undefined, + description: item.description || undefined, + factor: item.factor ?? undefined + })); }, async getComplexityFactors(): Promise { - if (useJiraAssets) { - return jiraAssetsService.getComplexityFactors(); - } - return mockDataService.getComplexityFactors(); + if (!useJiraAssets) return mockDataService.getComplexityFactors(); + const items = await cmdbService.getObjects('ApplicationManagementComplexityFactor'); + return items.map(item => ({ + objectId: item.id, + key: item.objectKey, + name: item.label, + summary: item.summary || undefined, + description: item.description || undefined, + factor: item.factor ?? undefined + })); }, async getNumberOfUsers(): Promise { - if (useJiraAssets) { - return jiraAssetsService.getNumberOfUsers(); - } - return mockDataService.getNumberOfUsers(); + if (!useJiraAssets) return mockDataService.getNumberOfUsers(); + const items = await cmdbService.getObjects('ApplicationManagementNumberOfUsers'); + return items.map(item => ({ + objectId: item.id, + key: item.objectKey, + name: item.label, + summary: item.examples || undefined, // Use examples as summary for display + factor: item.factor ?? undefined, + order: item.order ?? undefined + })); }, async getGovernanceModels(): Promise { - if (useJiraAssets) { - return jiraAssetsService.getGovernanceModels(); - } - return mockDataService.getGovernanceModels(); + if (!useJiraAssets) return mockDataService.getGovernanceModels(); + const items = await cmdbService.getObjects('IctGovernanceModel'); + return items.map(item => ({ + objectId: item.id, + key: item.objectKey, + name: item.label, + summary: item.summary || undefined, + remarks: item.remarks || undefined, + application: item.application || undefined + })); }, async getOrganisations(): Promise { - if (useJiraAssets) { - return jiraAssetsService.getOrganisations(); - } - return mockDataService.getOrganisations(); + if (!useJiraAssets) return mockDataService.getOrganisations(); + const items = await cmdbService.getObjects('Organisation'); + return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label })); }, async getHostingTypes(): Promise { - if (useJiraAssets) { - return jiraAssetsService.getHostingTypes(); - } - return mockDataService.getHostingTypes(); + if (!useJiraAssets) return mockDataService.getHostingTypes(); + const items = await cmdbService.getObjects('HostingType'); + 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 { - if (useJiraAssets) { - return jiraAssetsService.getBusinessImpactAnalyses(); - } - return mockDataService.getBusinessImpactAnalyses(); + if (!useJiraAssets) return mockDataService.getBusinessImpactAnalyses(); + const items = await cmdbService.getObjects('BusinessImpactAnalyse'); + return items.map(item => ({ + objectId: item.id, + key: item.objectKey, + name: item.label, + summary: item.description || undefined, // Use description as summary for display + indicators: item.indicators || undefined + })); }, async getApplicationManagementHosting(): Promise { - if (useJiraAssets) { - return jiraAssetsService.getApplicationManagementHosting(); - } - return mockDataService.getApplicationManagementHosting(); + if (!useJiraAssets) return mockDataService.getApplicationManagementHosting(); + const items = await cmdbService.getObjects('ApplicationManagementHosting'); + return items.map(item => ({ + objectId: item.id, + key: item.objectKey, + name: item.label, + description: item.description || undefined, + })); }, async getApplicationManagementTAM(): Promise { - if (useJiraAssets) { - return jiraAssetsService.getApplicationManagementTAM(); - } - return mockDataService.getApplicationManagementTAM(); - }, - - async getStats(includeDistributions: boolean = true) { - if (useJiraAssets) { - return jiraAssetsService.getStats(includeDistributions); - } - return mockDataService.getStats(); + if (!useJiraAssets) return mockDataService.getApplicationManagementTAM(); + const items = await cmdbService.getObjects('ApplicationManagementTam'); + return items.map(item => ({ + objectId: item.id, + key: item.objectKey, + name: item.label, + summary: item.description || undefined, // Use description as summary for display + })); }, async getApplicationFunctions(): Promise { - if (useJiraAssets) { - return jiraAssetsService.getApplicationFunctions(); - } - return mockDataService.getApplicationFunctions(); + if (!useJiraAssets) return mockDataService.getApplicationFunctions(); + const items = await cmdbService.getObjects('ApplicationFunction'); + return items.map(item => ({ + objectId: item.id, + key: item.objectKey, + name: item.label, + description: item.description || undefined, + keywords: item.keywords || undefined, + applicationFunctionCategory: item.applicationFunctionCategory ? { + objectId: String(item.applicationFunctionCategory.objectId), + key: item.applicationFunctionCategory.objectKey || '', + name: item.applicationFunctionCategory.label || '', + } : undefined, + })); }, async getApplicationFunctionCategories(): Promise { - if (useJiraAssets) { - return jiraAssetsService.getApplicationFunctionCategories(); - } - return mockDataService.getApplicationFunctionCategories(); + if (!useJiraAssets) return mockDataService.getApplicationFunctionCategories(); + const items = await cmdbService.getObjects('ApplicationFunctionCategory'); + return items.map(item => ({ + objectId: item.id, + key: item.objectKey, + name: item.label, + description: item.description || undefined, + })); }, - async getApplicationClusters(): Promise { - if (useJiraAssets) { - return jiraAssetsService.getApplicationClusters(); - } - return mockDataService.getApplicationClusters(); + async getApplicationSubteams(): Promise { + if (!useJiraAssets) return []; // Mock mode: no subteams + // Use jiraAssetsService directly as schema doesn't include this object type + return jiraAssetsService.getApplicationSubteams(); + }, + + async getApplicationTeams(): Promise { + if (!useJiraAssets) return []; // Mock mode: no teams + // Use jiraAssetsService directly as schema doesn't include this object type + return jiraAssetsService.getApplicationTeams(); + }, + + async getSubteamToTeamMapping(): Promise> { + if (!useJiraAssets) return {}; // Mock mode: no mapping + // Convert Map to plain object for JSON serialization + const mapping = await jiraAssetsService.getSubteamToTeamMapping(); + const result: Record = {}; + mapping.forEach((team, subteamId) => { + result[subteamId] = team; + }); + return result; }, async getApplicationTypes(): Promise { - if (useJiraAssets) { - return jiraAssetsService.getApplicationTypes(); - } - return mockDataService.getApplicationTypes(); + if (!useJiraAssets) return mockDataService.getApplicationTypes(); + const items = await cmdbService.getObjects('ApplicationManagementApplicationType'); + return items.map(item => ({ + objectId: item.id, + key: item.objectKey, + name: item.label, + summary: item.description || undefined, // Use description as summary for display + })); }, async getBusinessImportance(): Promise { - if (useJiraAssets) { - return jiraAssetsService.getBusinessImportance(); - } - return mockDataService.getBusinessImportance(); + if (!useJiraAssets) return mockDataService.getBusinessImportance(); + const items = await cmdbService.getObjects('BusinessImportance'); + return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label })); }, + // =========================================================================== + // Dashboard / Stats + // =========================================================================== + + async getStats(includeDistributions: boolean = true) { + if (!useJiraAssets) return mockDataService.getStats(); + + const allApps = await cmdbService.getObjects('ApplicationComponent'); + + // Statuses to exclude for most metrics + const excludedStatuses = ['Closed', 'Deprecated']; + + // Filter: apps excluding Closed and Deprecated + const activeApps = allApps.filter(app => !excludedStatuses.includes(app.status || '')); + + // Totaal applicaties: Excluding Closed and Deprecated + const totalApplications = activeApps.length; + + // Geclassificeerd: Apps where ICT Governance Model is NOT empty + const classifiedCount = activeApps.filter(app => { + const gov = app.ictGovernanceModel; + // Check if it's an ObjectReference with a valid objectId + return gov && typeof gov === 'object' && gov.objectId; + }).length; + + // Nog te classificeren: Total - Geclassificeerd + const unclassifiedCount = totalApplications - classifiedCount; + + // ApplicationFunction ingevuld: Apps where ApplicationFunction is NOT empty + const withApplicationFunction = activeApps.filter(app => { + const appFunc = app.applicationFunction; + return Array.isArray(appFunc) && appFunc.length > 0; + }).length; + const applicationFunctionPercentage = totalApplications > 0 + ? Math.round((withApplicationFunction / totalApplications) * 100) + : 0; + + // Total ALL applications (including Closed/Deprecated) for status distribution + const totalAllApplications = allApps.length; + + const stats = { + totalApplications, // Excluding Closed/Deprecated + totalAllApplications, // Including all statuses + classifiedCount, + unclassifiedCount, + withApplicationFunction, + applicationFunctionPercentage, + cacheStatus: cmdbService.getCacheStats(), + }; + + if (includeDistributions) { + // Verdeling per status: ALL applications + const byStatus: Record = {}; + for (const app of allApps) { + const status = app.status || 'Undefined'; + byStatus[status] = (byStatus[status] || 0) + 1; + } + + // Verdeling per regiemodel: Excluding Closed and Deprecated + const byGovernanceModel: Record = {}; + for (const app of activeApps) { + const gov = app.ictGovernanceModel; + let govLabel = 'Niet ingesteld'; + if (gov && typeof gov === 'object' && gov.label) { + govLabel = gov.label; + } + byGovernanceModel[govLabel] = (byGovernanceModel[govLabel] || 0) + 1; + } + + return { + ...stats, + byStatus, + byGovernanceModel, + // Keep old names for backwards compatibility with dashboard route + governanceDistribution: byGovernanceModel, + statusDistribution: byStatus, + }; + } + + return stats; + }, + + async getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise { + if (!useJiraAssets) return mockDataService.getTeamDashboardData(excludedStatuses); + + // Use jiraAssetsService directly as it has proper Team/Subteam field parsing + return jiraAssetsService.getTeamDashboardData(excludedStatuses); + }, + + // =========================================================================== + // Utility + // =========================================================================== + isUsingJiraAssets(): boolean { return useJiraAssets; }, async testConnection(): Promise { - if (useJiraAssets) { - return jiraAssetsService.testConnection(); - } - return true; + if (!useJiraAssets) return true; + return jiraAssetsClient.testConnection(); }, - async getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise { - if (useJiraAssets) { - return jiraAssetsService.getTeamDashboardData(excludedStatuses); - } - return mockDataService.getTeamDashboardData(excludedStatuses); + /** + * Get cache status + */ + getCacheStatus(): CacheStats { + return cacheStore.getStats(); + }, + + /** + * Check if cache is warm + */ + isCacheWarm(): boolean { + return 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(); }, }; diff --git a/backend/src/services/effortCalculation.ts b/backend/src/services/effortCalculation.ts index 5640240..8ec2654 100644 --- a/backend/src/services/effortCalculation.ts +++ b/backend/src/services/effortCalculation.ts @@ -8,10 +8,15 @@ import { HostingRule, } from '../config/effortCalculation.js'; import { readFileSync, existsSync } from 'fs'; -import { join } from 'path'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; import { logger } from './logger.js'; import type { ApplicationDetails, EffortCalculationBreakdown } from '../types/index.js'; +// Get __dirname equivalent for ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + // Path to the configuration file (v25) const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config-v25.json'); diff --git a/backend/src/services/jiraAssets.ts b/backend/src/services/jiraAssets.ts index 2a6792d..627200b 100644 --- a/backend/src/services/jiraAssets.ts +++ b/backend/src/services/jiraAssets.ts @@ -1,6 +1,7 @@ import { config } from '../config/env.js'; import { logger } from './logger.js'; import { calculateRequiredEffortApplicationManagement, calculateRequiredEffortWithMinMax } from './effortCalculation.js'; +import { getAttributeId, OBJECT_TYPES } from '../generated/jira-schema.js'; import type { ApplicationDetails, ApplicationListItem, @@ -38,12 +39,17 @@ const ATTRIBUTE_NAMES = { COMPLEXITY_FACTOR: 'Application Management - Complexity Factor', NUMBER_OF_USERS: 'Application Management - Number of Users', GOVERNANCE_MODEL: 'ICT Governance Model', - APPLICATION_CLUSTER: 'Application Management - Application Cluster', + // "Application Management - Subteam" on ApplicationComponent references Subteam objects. + // Subteam objects have "Application Management - Team" which references Team objects. + // So Team is looked up via Subteam. + APPLICATION_SUBTEAM: 'Application Management - Subteam', // On ApplicationComponent -> refs Subteam + SUBTEAM_TEAM: 'Application Management - Team', // On Subteam -> refs Team APPLICATION_TYPE: 'Application Management - Application Type', PLATFORM: 'Platform', OVERRIDE_FTE: 'Application Management - Override FTE', APPLICATION_MANAGEMENT_HOSTING: 'Application Management - Hosting', APPLICATION_MANAGEMENT_TAM: 'Application Management - TAM', + TECHNISCHE_ARCHITECTUUR: 'Technische Architectuur (TA)', }; // Jira Data Center (Insight) uses different API endpoints than Jira Cloud (Assets) @@ -497,10 +503,12 @@ class JiraAssetsService { } // Parse Jira object for list view (summary) with optional schema for attribute lookup + // OPTIMIZED: Does lightweight parsing without calling parseJiraObjectDetails + // This avoids duplicate effort calculation (was calculating effort 2x per app) private async parseJiraObject(obj: JiraAssetsObject, attrSchema?: Map): Promise { const appFunctions = this.getReferenceValuesWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_FUNCTION, attrSchema); - // Ensure factor caches are populated + // Ensure factor caches are populated (should be instant after initial population) await this.ensureFactorCaches(); // Get reference values and enrich with factors @@ -517,29 +525,80 @@ class JiraAssetsService { this.numberOfUsersCache ); - const applicationDetails = await this.parseJiraObjectDetails(obj, attrSchema); + // Get other reference values needed for list view and effort calculation + const governanceModel = this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.GOVERNANCE_MODEL, attrSchema); + const applicationType = this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_TYPE, attrSchema); + const businessImpactAnalyse = this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.BUSINESS_IMPACT_ANALYSE, attrSchema); + const applicationManagementHosting = this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_HOSTING, attrSchema); + const applicationManagementTAM = this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_TAM, attrSchema); - // Calculate min/max FTE - const minMaxFTE = calculateRequiredEffortWithMinMax(applicationDetails); + // Get override FTE + const overrideFTE = (() => { + const value = this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.OVERRIDE_FTE, attrSchema); + if (!value || value === '') return null; + const parsed = parseFloat(value); + return isNaN(parsed) ? null : parsed; + })(); + + // Build minimal ApplicationDetails for effort calculation (SINGLE calculation) + const minimalDetails: ApplicationDetails = { + id: obj.id.toString(), + key: obj.objectKey, + name: obj.label, + description: null, + status: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.STATUS, attrSchema) as ApplicationStatus | null, + searchReference: null, + supplierProduct: null, + businessOwner: null, + systemOwner: null, + functionalApplicationManagement: null, + technicalApplicationManagement: null, + technicalApplicationManagementPrimary: null, + technicalApplicationManagementSecondary: null, + medischeTechniek: false, + organisation: null, + businessImpactAnalyse, + businessImportance: null, + governanceModel, + dynamicsFactor, + complexityFactor, + numberOfUsers, + applicationType, + applicationSubteam: null, + applicationTeam: null, + applicationFunctions: [], + hostingType: null, + platform: null, + applicationManagementHosting, + applicationManagementTAM, + overrideFTE, + requiredEffortApplicationManagement: null, + technischeArchitectuur: null, + }; + + // Calculate effort ONCE (previously calculated 2x per app) + const effortResult = calculateRequiredEffortWithMinMax(minimalDetails); return { id: obj.id.toString(), key: obj.objectKey, name: obj.label, - status: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.STATUS, attrSchema) as ApplicationStatus | null, + status: minimalDetails.status, applicationFunctions: this.enrichApplicationFunctions(appFunctions), - governanceModel: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.GOVERNANCE_MODEL, attrSchema), + governanceModel, dynamicsFactor, complexityFactor, - applicationCluster: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_CLUSTER, attrSchema), - applicationType: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_TYPE, attrSchema), + // "Application Management - Subteam" on ApplicationComponent references Subteam objects + applicationSubteam: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_SUBTEAM, attrSchema), + applicationTeam: null, // Team is looked up via Subteam, not directly on ApplicationComponent + applicationType, platform: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.PLATFORM, attrSchema), - requiredEffortApplicationManagement: applicationDetails.requiredEffortApplicationManagement, - minFTE: minMaxFTE.minFTE, - maxFTE: minMaxFTE.maxFTE, - overrideFTE: applicationDetails.overrideFTE, - applicationManagementHosting: applicationDetails.applicationManagementHosting, - applicationManagementTAM: applicationDetails.applicationManagementTAM, + requiredEffortApplicationManagement: effortResult.finalEffort, + minFTE: effortResult.minFTE, + maxFTE: effortResult.maxFTE, + overrideFTE, + applicationManagementHosting, + applicationManagementTAM, }; } @@ -581,28 +640,8 @@ class JiraAssetsService { businessOwner: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.BUSINESS_OWNER, attrSchema), functionalApplicationManagement: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.FAM, attrSchema), technicalApplicationManagement: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.TAM, attrSchema), - technicalApplicationManagementPrimary: config.jiraAttrTechnicalApplicationManagementPrimary && config.jiraAttrTechnicalApplicationManagementPrimary.trim() !== '' - ? (() => { - const value = this.getAttributeValueById(obj, parseInt(config.jiraAttrTechnicalApplicationManagementPrimary, 10), attrSchema); - if (value) { - logger.debug(`Found Technical Application Management Primary (attr ${config.jiraAttrTechnicalApplicationManagementPrimary}): ${value}`); - } else { - logger.debug(`Technical Application Management Primary (attr ${config.jiraAttrTechnicalApplicationManagementPrimary}) not found or empty`); - } - return value; - })() - : null, - technicalApplicationManagementSecondary: config.jiraAttrTechnicalApplicationManagementSecondary && config.jiraAttrTechnicalApplicationManagementSecondary.trim() !== '' - ? (() => { - const value = this.getAttributeValueById(obj, parseInt(config.jiraAttrTechnicalApplicationManagementSecondary, 10), attrSchema); - if (value) { - logger.debug(`Found Technical Application Management Secondary (attr ${config.jiraAttrTechnicalApplicationManagementSecondary}): ${value}`); - } else { - logger.debug(`Technical Application Management Secondary (attr ${config.jiraAttrTechnicalApplicationManagementSecondary}) not found or empty`); - } - return value; - })() - : null, + technicalApplicationManagementPrimary: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.TAM_PRIMARY, attrSchema), + technicalApplicationManagementSecondary: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.TAM_SECONDARY, attrSchema), medischeTechniek: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.MEDISCHE_TECHNIEK, attrSchema) === 'true' || this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.MEDISCHE_TECHNIEK, attrSchema) === 'Ja', applicationFunctions: this.enrichApplicationFunctions(appFunctions), @@ -610,26 +649,21 @@ class JiraAssetsService { complexityFactor, numberOfUsers, governanceModel: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.GOVERNANCE_MODEL, attrSchema), - applicationCluster: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_CLUSTER, attrSchema), + // "Application Management - Subteam" on ApplicationComponent references Subteam objects + applicationSubteam: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_SUBTEAM, attrSchema), + applicationTeam: null, // Team is looked up via Subteam, not directly on ApplicationComponent applicationType: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_TYPE, attrSchema), platform: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.PLATFORM, attrSchema), requiredEffortApplicationManagement: null, - technischeArchitectuur: this.getAttributeValueById(obj, parseInt(config.jiraAttrTechnischeArchitectuur, 10), attrSchema), - overrideFTE: config.jiraAttrOverrideFTE - ? (() => { - const value = this.getAttributeValueById(obj, parseInt(config.jiraAttrOverrideFTE, 10), attrSchema); - // Parse float value, return null if empty or invalid - if (!value || value === '') return null; - const parsed = parseFloat(value); - return isNaN(parsed) ? null : parsed; - })() - : null, - applicationManagementHosting: config.jiraAttrApplicationManagementHosting - ? this.getReferenceValueById(obj, parseInt(config.jiraAttrApplicationManagementHosting, 10), attrSchema) - : null, - applicationManagementTAM: config.jiraAttrApplicationManagementTAM - ? this.getReferenceValueById(obj, parseInt(config.jiraAttrApplicationManagementTAM, 10), attrSchema) - : null, + technischeArchitectuur: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.TECHNISCHE_ARCHITECTUUR, attrSchema), + overrideFTE: (() => { + const value = this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.OVERRIDE_FTE, attrSchema); + if (!value || value === '') return null; + const parsed = parseFloat(value); + return isNaN(parsed) ? null : parsed; + })(), + applicationManagementHosting: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_HOSTING, attrSchema), + applicationManagementTAM: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_TAM, attrSchema), }; // Calculate required effort application management @@ -680,10 +714,14 @@ class JiraAssetsService { conditions.push('"Application Management - Complexity Factor" IS NOT EMPTY'); } - if (filters.applicationCluster === 'empty') { - conditions.push('"Application Management - Application Cluster" IS EMPTY'); - } else if (filters.applicationCluster === 'filled') { - conditions.push('"Application Management - Application Cluster" IS NOT EMPTY'); + if (filters.applicationSubteam === 'empty') { + conditions.push('"Application Management - Subteam" IS EMPTY'); + } else if (filters.applicationSubteam === 'filled') { + conditions.push('"Application Management - Subteam" IS NOT EMPTY'); + } else if (filters.applicationSubteam && filters.applicationSubteam !== 'all') { + // Filter by specific subteam name + const subteamName = filters.applicationSubteam.replace(/"/g, '\\"'); + conditions.push(`"Application Management - Subteam" = "${subteamName}"`); } if (filters.applicationType === 'empty') { @@ -845,6 +883,22 @@ class JiraAssetsService { ): Promise { await this.detectApiType(); + // Get attribute IDs from the generated schema (no .env needed!) + const ATTR_IDS = { + applicationFunction: getAttributeId('ApplicationComponent', 'ApplicationFunction'), + dynamicsFactor: getAttributeId('ApplicationComponent', 'Application Management - Dynamics Factor'), + complexityFactor: getAttributeId('ApplicationComponent', 'Application Management - Complexity Factor'), + numberOfUsers: getAttributeId('ApplicationComponent', 'Application Management - Number of Users'), + governanceModel: getAttributeId('ApplicationComponent', 'ICT Governance Model'), + applicationSubteam: getAttributeId('ApplicationComponent', 'Application Management - Subteam'), + applicationType: getAttributeId('ApplicationComponent', 'Application Management - Application Type'), + hostingType: getAttributeId('ApplicationComponent', 'Application Component Hosting Type'), + businessImpactAnalyse: getAttributeId('ApplicationComponent', 'Business Impact Analyse'), + overrideFTE: getAttributeId('ApplicationComponent', 'Application Management - Override FTE'), + applicationManagementHosting: getAttributeId('ApplicationComponent', 'Application Management - Hosting'), + applicationManagementTAM: getAttributeId('ApplicationComponent', 'Application Management - TAM'), + }; + // For Jira Data Center (Insight), reference attributes need object keys // For clearing a field, send an empty array const attributes: Array<{ @@ -856,7 +910,7 @@ class JiraAssetsService { if (updates.applicationFunctions !== undefined) { if (updates.applicationFunctions.length > 0) { attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrApplicationFunction, 10), + objectTypeAttributeId: ATTR_IDS.applicationFunction, objectAttributeValues: updates.applicationFunctions.map((key) => ({ value: key, // Use the object key as value for reference attributes })), @@ -864,7 +918,7 @@ class JiraAssetsService { } else { // Clear the field by sending empty array attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrApplicationFunction, 10), + objectTypeAttributeId: ATTR_IDS.applicationFunction, objectAttributeValues: [], }); } @@ -872,7 +926,7 @@ class JiraAssetsService { if (updates.dynamicsFactor) { attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrDynamicsFactor, 10), + objectTypeAttributeId: ATTR_IDS.dynamicsFactor, objectAttributeValues: [ { value: updates.dynamicsFactor }, ], @@ -881,7 +935,7 @@ class JiraAssetsService { if (updates.complexityFactor) { attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrComplexityFactor, 10), + objectTypeAttributeId: ATTR_IDS.complexityFactor, objectAttributeValues: [ { value: updates.complexityFactor }, ], @@ -890,7 +944,7 @@ class JiraAssetsService { if (updates.numberOfUsers) { attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrNumberOfUsers, 10), + objectTypeAttributeId: ATTR_IDS.numberOfUsers, objectAttributeValues: [ { value: updates.numberOfUsers }, ], @@ -899,25 +953,28 @@ class JiraAssetsService { if (updates.governanceModel) { attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrGovernanceModel, 10), + objectTypeAttributeId: ATTR_IDS.governanceModel, objectAttributeValues: [ { value: updates.governanceModel }, ], }); } - if (updates.applicationCluster) { + if (updates.applicationSubteam) { attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrApplicationCluster, 10), + objectTypeAttributeId: ATTR_IDS.applicationSubteam, objectAttributeValues: [ - { value: updates.applicationCluster }, + { value: updates.applicationSubteam }, ], }); } + // Note: applicationTeam is not a direct attribute on ApplicationComponent + // Team is derived from Subteam, so we don't update it here + if (updates.applicationType) { attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrApplicationType, 10), + objectTypeAttributeId: ATTR_IDS.applicationType, objectAttributeValues: [ { value: updates.applicationType }, ], @@ -926,7 +983,7 @@ class JiraAssetsService { if (updates.hostingType) { attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrHostingType, 10), + objectTypeAttributeId: ATTR_IDS.hostingType, objectAttributeValues: [ { value: updates.hostingType }, ], @@ -935,7 +992,7 @@ class JiraAssetsService { if (updates.businessImpactAnalyse) { attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrBusinessImpactAnalyse, 10), + objectTypeAttributeId: ATTR_IDS.businessImpactAnalyse, objectAttributeValues: [ { value: updates.businessImpactAnalyse }, ], @@ -947,13 +1004,13 @@ class JiraAssetsService { if (updates.overrideFTE === null || updates.overrideFTE === undefined) { // Clear the field attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrOverrideFTE, 10), + objectTypeAttributeId: ATTR_IDS.overrideFTE, objectAttributeValues: [], }); } else { // Set the float value attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrOverrideFTE, 10), + objectTypeAttributeId: ATTR_IDS.overrideFTE, objectAttributeValues: [ { value: updates.overrideFTE.toString() }, ], @@ -965,13 +1022,13 @@ class JiraAssetsService { if (updates.applicationManagementHosting !== undefined) { if (updates.applicationManagementHosting) { attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrApplicationManagementHosting, 10), + objectTypeAttributeId: ATTR_IDS.applicationManagementHosting, objectAttributeValues: [{ value: updates.applicationManagementHosting }], }); } else { // Clear the field attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrApplicationManagementHosting, 10), + objectTypeAttributeId: ATTR_IDS.applicationManagementHosting, objectAttributeValues: [], }); } @@ -981,13 +1038,13 @@ class JiraAssetsService { if (updates.applicationManagementTAM !== undefined) { if (updates.applicationManagementTAM) { attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrApplicationManagementTAM, 10), + objectTypeAttributeId: ATTR_IDS.applicationManagementTAM, objectAttributeValues: [{ value: updates.applicationManagementTAM }], }); } else { // Clear the field attributes.push({ - objectTypeAttributeId: parseInt(config.jiraAttrApplicationManagementTAM, 10), + objectTypeAttributeId: ATTR_IDS.applicationManagementTAM, objectAttributeValues: [], }); } @@ -995,7 +1052,7 @@ class JiraAssetsService { // Log the update request for debugging logger.info(`=== Updating application ${id} ===`); - logger.info(`Config attribute IDs: appFunc=${config.jiraAttrApplicationFunction}, dynamics=${config.jiraAttrDynamicsFactor}, complexity=${config.jiraAttrComplexityFactor}, users=${config.jiraAttrNumberOfUsers}, governance=${config.jiraAttrGovernanceModel}`); + logger.info(`Schema attribute IDs: subteam=${ATTR_IDS.applicationSubteam}, dynamics=${ATTR_IDS.dynamicsFactor}, complexity=${ATTR_IDS.complexityFactor}, governance=${ATTR_IDS.governanceModel}`); logger.info(`Updates received: ${JSON.stringify(updates)}`); logger.info(`Attributes to update: ${JSON.stringify(attributes, null, 2)}`); @@ -1183,6 +1240,12 @@ class JiraAssetsService { result.indicators = stripHtmlTags(indicators); } + // Extract Type attribute for Team objects (Business, Enabling, Staf) + const teamTypeValue = this.getAttributeValueWithSchema(obj, 'Type', attrSchema); + if (teamTypeValue) { + result.teamType = teamTypeValue; + } + return result; }); @@ -1277,10 +1340,109 @@ class JiraAssetsService { return models.sort((a, b) => a.name.localeCompare(b.name)); } - async getApplicationClusters(): Promise { - const clusters = await this.getReferenceObjects('Application Management - Application Cluster'); + async getApplicationSubteams(): Promise { + // Subteam objects are of type "Application Management - Subteam" + const subteams = await this.getReferenceObjects('Application Management - Subteam'); // Sort by Name - return clusters.sort((a, b) => a.name.localeCompare(b.name)); + return subteams.sort((a, b) => a.name.localeCompare(b.name)); + } + + async getApplicationTeams(): Promise { + // Team objects are of type "Application Management - Team" + const teams = await this.getReferenceObjects('Application Management - Team'); + // Sort by Name + return teams.sort((a, b) => a.name.localeCompare(b.name)); + } + + // Cache for Subteam -> Team mapping + private subteamToTeamCache: Map | null = null; + + async getSubteamToTeamMapping(): Promise> { + if (this.subteamToTeamCache) { + return this.subteamToTeamCache; + } + + // Fetch all subteams with their Team attribute + await this.detectApiType(); + const objectTypeName = 'Application Management - Subteam'; + + // Build IQL query for Subteam objects + const iql = `objectType = "${objectTypeName}"`; + + const mapping = new Map(); + + try { + // First, fetch all teams with their Type attribute to enrich the team references + const allTeams = await this.getApplicationTeams(); + const teamsById = new Map(); + for (const team of allTeams) { + teamsById.set(team.objectId, team); + } + + let response: JiraAssetsSearchResponse; + + if (this.isDataCenter) { + const params = new URLSearchParams({ + iql, + resultPerPage: '500', + includeAttributes: 'true', + objectSchemaId: config.jiraSchemaId, + }); + response = await this.request( + `/iql/objects?${params.toString()}` + ); + } else { + response = await this.request( + '/aql/objects', + { + method: 'POST', + body: JSON.stringify({ + qlQuery: iql, + resultPerPage: 500, + includeAttributes: true, + }), + } + ); + } + + if (response.objectEntries) { + // Get attribute schema for lookup + let attrSchema: Map | undefined; + if (response.objectEntries.length > 0) { + const objectTypeId = response.objectEntries[0].objectType?.id; + if (objectTypeId) { + attrSchema = await this.fetchAttributeSchema(objectTypeId); + } + } + + for (const obj of response.objectEntries) { + const subteamId = obj.id.toString(); + // Get the Team reference from the Subteam + const teamRef = this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.SUBTEAM_TEAM, attrSchema); + + // Enrich the team reference with Type attribute if available + if (teamRef) { + const enrichedTeam = teamsById.get(teamRef.objectId); + if (enrichedTeam) { + // Use the enriched team with all attributes including teamType + mapping.set(subteamId, enrichedTeam); + } else { + // Fallback to basic team reference if not found in enriched list + mapping.set(subteamId, teamRef); + } + } else { + mapping.set(subteamId, null); + } + } + } + + logger.info(`Loaded Subteam->Team mapping: ${mapping.size} subteams`); + this.subteamToTeamCache = mapping; + return mapping; + } catch (error) { + logger.error('Failed to load Subteam->Team mapping', error); + return mapping; + } } async getApplicationTypes(): Promise { @@ -1433,7 +1595,7 @@ class JiraAssetsService { // Fetch all applications in batches and calculate all statistics in memory // Using smaller batch size to avoid API timeouts const maxApplicationsToFetch = 2000; // Limit for better performance - const pageSize = parseInt(config.jiraApiBatchSize || '15', 10); // Use configured batch size (small to avoid timeouts) + const pageSize = config.jiraApiBatchSize || 15; // Use configured batch size (small to avoid timeouts) let currentPage = 1; let hasMore = true; let totalFetched = 0; @@ -1586,6 +1748,13 @@ class JiraAssetsService { classifiedCount = classifiedResponse.totalFilterCount || 0; } + // Sanity check: classifiedCount can never exceed totalApplications + // This can happen if queries return slightly different results due to timing + if (classifiedCount > totalApplications) { + logger.warn(`Dashboard stats: classifiedCount (${classifiedCount}) exceeds totalApplications (${totalApplications}). Adjusting.`); + classifiedCount = totalApplications; + } + const result = { totalApplications, classifiedCount, @@ -1623,11 +1792,13 @@ class JiraAssetsService { async getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise { try { - // Check cache first (only if no status filter is applied, as cache doesn't account for filters) - // For now, we'll always fetch fresh data when filters are applied - // TODO: Could implement cache key based on excludedStatuses if needed for performance - const hasFilters = excludedStatuses.length > 0; - if (!hasFilters && this.teamDashboardCache) { + // Check cache first + // Default filters are ['Closed', 'Deprecated'] - we can still use cache for these + const DEFAULT_EXCLUDED_STATUSES = ['Closed', 'Deprecated']; + const isDefaultFilter = excludedStatuses.length === DEFAULT_EXCLUDED_STATUSES.length && + excludedStatuses.every(s => DEFAULT_EXCLUDED_STATUSES.includes(s)); + const hasCustomFilters = excludedStatuses.length > 0 && !isDefaultFilter; + if (!hasCustomFilters && this.teamDashboardCache) { const age = Date.now() - this.teamDashboardCache.timestamp; if (age < this.TEAM_DASHBOARD_CACHE_TTL) { logger.debug('Returning team dashboard data from cache'); @@ -1637,16 +1808,15 @@ class JiraAssetsService { await this.detectApiType(); - // Use a more efficient approach: fetch in batches but only parse minimal data + // Fetch in batches const emptyFilters: SearchFilters = {}; const qlQuery = this.buildAqlQuery(emptyFilters); logger.info(`Fetching team dashboard data with query: ${qlQuery}`); - // Fetch all applications in batches to avoid timeout, but parse them efficiently - const batchSize = config.jiraApiBatchSize; // Configurable batch size from .env (default: 15) - let page = 1; - let hasMore = true; + // Use larger batch size for team dashboard (100 apps per request instead of default 40) + // This reduces the number of API calls needed + const batchSize = Math.max(config.jiraApiBatchSize, 100); const allApplications: ApplicationListItem[] = []; // Fetch attribute schema once @@ -1655,7 +1825,6 @@ class JiraAssetsService { if (this.attributeSchemaCache.has(objectTypeName)) { attrSchema = this.attributeSchemaCache.get(objectTypeName); } else { - // We need to fetch one object first to get the object type ID const testParams = new URLSearchParams({ iql: qlQuery, page: '1', @@ -1682,7 +1851,7 @@ class JiraAssetsService { this.ensureFactorCaches(), ]); - // First, get total count to determine how many batches we need + // Get total count let firstResponse: JiraAssetsSearchResponse; if (this.isDataCenter) { const params = new URLSearchParams({ @@ -1715,14 +1884,14 @@ class JiraAssetsService { const totalPages = Math.ceil(totalCount / batchSize); logger.info(`Total applications: ${totalCount}, will fetch in ${totalPages} batches of ${batchSize}`); - // Fetch all pages in parallel (but limit concurrency to avoid overwhelming the API) - const maxConcurrentBatches = 5; // Fetch 5 batches at a time + // Fetch all pages in parallel with increased concurrency + // Using 10 concurrent requests (up from 5) for faster loading + const maxConcurrentBatches = 10; const allPages: number[] = []; for (let i = 1; i <= totalPages; i++) { allPages.push(i); } - // Process pages in chunks to limit concurrency for (let i = 0; i < allPages.length; i += maxConcurrentBatches) { const pageChunk = allPages.slice(i, i + maxConcurrentBatches); @@ -1760,7 +1929,6 @@ class JiraAssetsService { return []; } - // Parse applications in parallel const batchApplications = await Promise.all( response.objectEntries.map((obj) => this.parseJiraObject(obj, attrSchema)) ); @@ -1786,7 +1954,7 @@ class JiraAssetsService { logger.info(`After status filter: ${filteredApplications.length} applications (excluded: ${excludedStatuses.join(', ')})`); - // Separate applications into Platforms, Workloads, and regular applications + // Separate into Platforms, Workloads, and regular applications const platforms: ApplicationListItem[] = []; const workloads: ApplicationListItem[] = []; const regularApplications: ApplicationListItem[] = []; @@ -1816,25 +1984,23 @@ class JiraAssetsService { workloadsByPlatform.get(platformId)!.push(workload); } - // Helper function to get effective FTE (override if present, otherwise calculated) + // Helper functions for FTE calculations const getEffectiveFTE = (app: ApplicationListItem): number => { return app.overrideFTE !== null && app.overrideFTE !== undefined ? app.overrideFTE : (app.requiredEffortApplicationManagement || 0); }; - // Helper function to get min FTE (override if present, otherwise minFTE, fallback to calculated) const getMinFTE = (app: ApplicationListItem): number => { if (app.overrideFTE !== null && app.overrideFTE !== undefined) { - return app.overrideFTE; // If override is set, min = override + return app.overrideFTE; } return app.minFTE ?? app.requiredEffortApplicationManagement ?? 0; }; - // Helper function to get max FTE (override if present, otherwise maxFTE, fallback to calculated) const getMaxFTE = (app: ApplicationListItem): number => { if (app.overrideFTE !== null && app.overrideFTE !== undefined) { - return app.overrideFTE; // If override is set, max = override + return app.overrideFTE; } return app.maxFTE ?? app.requiredEffortApplicationManagement ?? 0; }; @@ -1855,75 +2021,23 @@ class JiraAssetsService { }); } - // Group all applications (regular + platforms + workloads) by cluster - const clusterMap = new Map(); - const unassigned: { - regular: ApplicationListItem[]; - platforms: import('../types/index.js').PlatformWithWorkloads[]; - } = { - regular: [], - platforms: [], - }; - - // Group regular applications by cluster - for (const app of regularApplications) { - if (app.applicationCluster) { - const clusterId = app.applicationCluster.objectId; - if (!clusterMap.has(clusterId)) { - clusterMap.set(clusterId, { regular: [], platforms: [] }); - } - clusterMap.get(clusterId)!.regular.push(app); - } else { - unassigned.regular.push(app); - } - } - - // Group platforms by cluster - for (const platformWithWorkloads of platformsWithWorkloads) { - const platform = platformWithWorkloads.platform; - if (platform.applicationCluster) { - const clusterId = platform.applicationCluster.objectId; - if (!clusterMap.has(clusterId)) { - clusterMap.set(clusterId, { regular: [], platforms: [] }); - } - clusterMap.get(clusterId)!.platforms.push(platformWithWorkloads); - } else { - unassigned.platforms.push(platformWithWorkloads); - } - } - - // Get all clusters to maintain order - const allClusters = await this.getApplicationClusters(); - const clusterMapById = new Map(allClusters.map(c => [c.objectId, c])); - - // Build cluster data - const clusters: import('../types/index.js').TeamDashboardCluster[] = []; - - // Add clusters in the order they appear in allClusters - for (const cluster of allClusters) { - const clusterData = clusterMap.get(cluster.objectId) || { regular: [], platforms: [] }; - const regularApps = clusterData.regular; - const platforms = clusterData.platforms; - - // Calculate total effort: regular apps + platforms (including their workloads) - const regularEffort = regularApps.reduce((sum, app) => - sum + getEffectiveFTE(app), 0 - ); - const platformsEffort = platforms.reduce((sum, p) => sum + p.totalEffort, 0); + // Helper function to calculate subteam KPIs + const calculateSubteamKPIs = ( + regularApps: ApplicationListItem[], + platformsList: import('../types/index.js').PlatformWithWorkloads[] + ) => { + const regularEffort = regularApps.reduce((sum, app) => sum + getEffectiveFTE(app), 0); + const platformsEffort = platformsList.reduce((sum, p) => sum + p.totalEffort, 0); const totalEffort = regularEffort + platformsEffort; - // Calculate min/max effort: sum of all min/max FTE values const regularMinEffort = regularApps.reduce((sum, app) => sum + getMinFTE(app), 0); const regularMaxEffort = regularApps.reduce((sum, app) => sum + getMaxFTE(app), 0); - const platformsMinEffort = platforms.reduce((sum, p) => { + const platformsMinEffort = platformsList.reduce((sum, p) => { const platformMin = getMinFTE(p.platform); const workloadsMin = p.workloads.reduce((s, w) => s + getMinFTE(w), 0); return sum + platformMin + workloadsMin; }, 0); - const platformsMaxEffort = platforms.reduce((sum, p) => { + const platformsMaxEffort = platformsList.reduce((sum, p) => { const platformMax = getMaxFTE(p.platform); const workloadsMax = p.workloads.reduce((s, w) => s + getMaxFTE(w), 0); return sum + platformMax + workloadsMax; @@ -1931,158 +2045,225 @@ class JiraAssetsService { const minEffort = regularMinEffort + platformsMinEffort; const maxEffort = regularMaxEffort + platformsMaxEffort; - // Calculate total application count: regular apps + platforms + workloads - const platformsCount = platforms.length; - const workloadsCount = platforms.reduce((sum, p) => sum + p.workloads.length, 0); + const platformsCount = platformsList.length; + const workloadsCount = platformsList.reduce((sum, p) => sum + p.workloads.length, 0); const applicationCount = regularApps.length + platformsCount + workloadsCount; - // Calculate governance model distribution (including platforms and workloads) const byGovernanceModel: Record = {}; for (const app of regularApps) { const govModel = app.governanceModel?.name || 'Niet ingesteld'; byGovernanceModel[govModel] = (byGovernanceModel[govModel] || 0) + 1; } - for (const platformWithWorkloads of platforms) { - const platform = platformWithWorkloads.platform; - const govModel = platform.governanceModel?.name || 'Niet ingesteld'; + for (const pwl of platformsList) { + const govModel = pwl.platform.governanceModel?.name || 'Niet ingesteld'; byGovernanceModel[govModel] = (byGovernanceModel[govModel] || 0) + 1; - // Also count workloads - for (const workload of platformWithWorkloads.workloads) { + for (const workload of pwl.workloads) { const workloadGovModel = workload.governanceModel?.name || 'Niet ingesteld'; byGovernanceModel[workloadGovModel] = (byGovernanceModel[workloadGovModel] || 0) + 1; } } - clusters.push({ - cluster, - applications: regularApps, - platforms, - totalEffort, - minEffort, - maxEffort, - applicationCount, - byGovernanceModel, + return { totalEffort, minEffort, maxEffort, applicationCount, byGovernanceModel }; + }; + + // HIERARCHICAL GROUPING: Team -> Subteam -> Applications + // Note: ApplicationComponent links to Subteam via "Application Management - Subteam" attribute + // Subteam links to Team via "Application Management - Team" attribute + // So we need to look up Team through Subteam + + type SubteamData = { + subteam: ReferenceValue | null; + regular: ApplicationListItem[]; + platforms: import('../types/index.js').PlatformWithWorkloads[]; + }; + + type TeamData = { + team: ReferenceValue | null; + subteams: Map; + }; + + // Load Subteam -> Team mapping + const subteamToTeam = await this.getSubteamToTeamMapping(); + logger.info(`Loaded ${subteamToTeam.size} subteam->team mappings`); + + const teamMap = new Map(); + const unassignedData: SubteamData = { + subteam: null, + regular: [], + platforms: [], + }; + + // Helper to get Team from Subteam + const getTeamForSubteam = (subteam: ReferenceValue | null): ReferenceValue | null => { + if (!subteam) return null; + return subteamToTeam.get(subteam.objectId) || null; + }; + + // Group regular applications by Team -> Subteam + for (const app of regularApplications) { + const subteam = app.applicationSubteam; + const team = getTeamForSubteam(subteam); + + if (team) { + const teamId = team.objectId; + if (!teamMap.has(teamId)) { + teamMap.set(teamId, { team, subteams: new Map() }); + } + const teamData = teamMap.get(teamId)!; + + const subteamId = subteam?.objectId || 'no-subteam'; + if (!teamData.subteams.has(subteamId)) { + teamData.subteams.set(subteamId, { + subteam: subteam || null, + regular: [], + platforms: [], + }); + } + teamData.subteams.get(subteamId)!.regular.push(app); + } else if (subteam) { + // Has subteam but no team - put under a virtual "Geen Team" team + const noTeamId = 'no-team'; + if (!teamMap.has(noTeamId)) { + teamMap.set(noTeamId, { + team: { objectId: noTeamId, key: 'NO-TEAM', name: 'Geen Team' }, + subteams: new Map() + }); + } + const teamData = teamMap.get(noTeamId)!; + const subteamId = subteam.objectId; + if (!teamData.subteams.has(subteamId)) { + teamData.subteams.set(subteamId, { + subteam: subteam, + regular: [], + platforms: [], + }); + } + teamData.subteams.get(subteamId)!.regular.push(app); + } else { + // No subteam assigned - goes to unassigned + unassignedData.regular.push(app); + } + } + + // Group platforms by Team -> Subteam + for (const pwl of platformsWithWorkloads) { + const platform = pwl.platform; + const subteam = platform.applicationSubteam; + const team = getTeamForSubteam(subteam); + + if (team) { + const teamId = team.objectId; + if (!teamMap.has(teamId)) { + teamMap.set(teamId, { team, subteams: new Map() }); + } + const teamData = teamMap.get(teamId)!; + + const subteamId = subteam?.objectId || 'no-subteam'; + if (!teamData.subteams.has(subteamId)) { + teamData.subteams.set(subteamId, { + subteam: subteam || null, + regular: [], + platforms: [], + }); + } + teamData.subteams.get(subteamId)!.platforms.push(pwl); + } else if (subteam) { + // Has subteam but no team - put under "Geen Team" + const noTeamId = 'no-team'; + if (!teamMap.has(noTeamId)) { + teamMap.set(noTeamId, { + team: { objectId: noTeamId, key: 'NO-TEAM', name: 'Geen Team' }, + subteams: new Map() + }); + } + const teamData = teamMap.get(noTeamId)!; + const subteamId = subteam.objectId; + if (!teamData.subteams.has(subteamId)) { + teamData.subteams.set(subteamId, { + subteam: subteam, + regular: [], + platforms: [], + }); + } + teamData.subteams.get(subteamId)!.platforms.push(pwl); + } else { + // No subteam assigned - goes to unassigned + unassignedData.platforms.push(pwl); + } + } + + logger.info(`Team dashboard: grouped ${regularApplications.length} regular apps and ${platformsWithWorkloads.length} platforms into ${teamMap.size} teams`); + + // Build the hierarchical result structure + const teams: import('../types/index.js').TeamDashboardTeam[] = []; + + // Process teams in alphabetical order + const sortedTeamIds = Array.from(teamMap.keys()).sort((a, b) => { + const teamA = teamMap.get(a)!.team?.name || ''; + const teamB = teamMap.get(b)!.team?.name || ''; + return teamA.localeCompare(teamB, 'nl', { sensitivity: 'base' }); + }); + + for (const teamId of sortedTeamIds) { + const teamData = teamMap.get(teamId)!; + // Use the team data from the application (already has basic info) + const fullTeam = teamData.team; + + const subteams: import('../types/index.js').TeamDashboardSubteam[] = []; + + // Sort subteams alphabetically (with "no-subteam" at the end) + const sortedSubteamEntries = Array.from(teamData.subteams.entries()).sort((a, b) => { + if (a[0] === 'no-subteam') return 1; + if (b[0] === 'no-subteam') return -1; + const nameA = a[1].subteam?.name || ''; + const nameB = b[1].subteam?.name || ''; + return nameA.localeCompare(nameB, 'nl', { sensitivity: 'base' }); + }); + + for (const [subteamId, subteamData] of sortedSubteamEntries) { + const kpis = calculateSubteamKPIs(subteamData.regular, subteamData.platforms); + + subteams.push({ + subteam: subteamData.subteam, + applications: subteamData.regular, + platforms: subteamData.platforms, + ...kpis, + }); + } + + // Aggregate team KPIs from all subteams + const teamTotalEffort = subteams.reduce((sum, s) => sum + s.totalEffort, 0); + const teamMinEffort = subteams.reduce((sum, s) => sum + s.minEffort, 0); + const teamMaxEffort = subteams.reduce((sum, s) => sum + s.maxEffort, 0); + const teamApplicationCount = subteams.reduce((sum, s) => sum + s.applicationCount, 0); + const teamByGovernanceModel: Record = {}; + for (const subteam of subteams) { + for (const [model, count] of Object.entries(subteam.byGovernanceModel)) { + teamByGovernanceModel[model] = (teamByGovernanceModel[model] || 0) + count; + } + } + + teams.push({ + team: fullTeam, + subteams, + totalEffort: teamTotalEffort, + minEffort: teamMinEffort, + maxEffort: teamMaxEffort, + applicationCount: teamApplicationCount, + byGovernanceModel: teamByGovernanceModel, }); } - // Add any clusters that have applications but aren't in allClusters (shouldn't happen, but be safe) - for (const [clusterId, clusterData] of clusterMap.entries()) { - if (!clusterMapById.has(clusterId)) { - // Find cluster from first application or platform - const cluster = clusterData.regular[0]?.applicationCluster || - clusterData.platforms[0]?.platform.applicationCluster; - if (cluster) { - const regularApps = clusterData.regular; - const platforms = clusterData.platforms; - - const regularEffort = regularApps.reduce((sum, app) => - sum + getEffectiveFTE(app), 0 - ); - const platformsEffort = platforms.reduce((sum, p) => sum + p.totalEffort, 0); - const totalEffort = regularEffort + platformsEffort; - - // Calculate min/max effort - const regularMinEffort = regularApps.reduce((sum, app) => sum + getMinFTE(app), 0); - const regularMaxEffort = regularApps.reduce((sum, app) => sum + getMaxFTE(app), 0); - const platformsMinEffort = platforms.reduce((sum, p) => { - const platformMin = getMinFTE(p.platform); - const workloadsMin = p.workloads.reduce((s, w) => s + getMinFTE(w), 0); - return sum + platformMin + workloadsMin; - }, 0); - const platformsMaxEffort = platforms.reduce((sum, p) => { - const platformMax = getMaxFTE(p.platform); - const workloadsMax = p.workloads.reduce((s, w) => s + getMaxFTE(w), 0); - return sum + platformMax + workloadsMax; - }, 0); - const minEffort = regularMinEffort + platformsMinEffort; - const maxEffort = regularMaxEffort + platformsMaxEffort; - - const platformsCount = platforms.length; - const workloadsCount = platforms.reduce((sum, p) => sum + p.workloads.length, 0); - const applicationCount = regularApps.length + platformsCount + workloadsCount; - - const byGovernanceModel: Record = {}; - for (const app of regularApps) { - const govModel = app.governanceModel?.name || 'Niet ingesteld'; - byGovernanceModel[govModel] = (byGovernanceModel[govModel] || 0) + 1; - } - for (const platformWithWorkloads of platforms) { - const platform = platformWithWorkloads.platform; - const govModel = platform.governanceModel?.name || 'Niet ingesteld'; - byGovernanceModel[govModel] = (byGovernanceModel[govModel] || 0) + 1; - for (const workload of platformWithWorkloads.workloads) { - const workloadGovModel = workload.governanceModel?.name || 'Niet ingesteld'; - byGovernanceModel[workloadGovModel] = (byGovernanceModel[workloadGovModel] || 0) + 1; - } - } - - clusters.push({ - cluster, - applications: regularApps, - platforms, - totalEffort, - minEffort, - maxEffort, - applicationCount, - byGovernanceModel, - }); - } - } - } - - // Calculate unassigned totals - const unassignedRegularEffort = unassigned.regular.reduce((sum, app) => - sum + getEffectiveFTE(app), 0 - ); - const unassignedPlatformsEffort = unassigned.platforms.reduce((sum, p) => sum + p.totalEffort, 0); - const unassignedTotalEffort = unassignedRegularEffort + unassignedPlatformsEffort; - - // Calculate unassigned min/max effort - const unassignedRegularMinEffort = unassigned.regular.reduce((sum, app) => sum + getMinFTE(app), 0); - const unassignedRegularMaxEffort = unassigned.regular.reduce((sum, app) => sum + getMaxFTE(app), 0); - const unassignedPlatformsMinEffort = unassigned.platforms.reduce((sum, p) => { - const platformMin = getMinFTE(p.platform); - const workloadsMin = p.workloads.reduce((s, w) => s + getMinFTE(w), 0); - return sum + platformMin + workloadsMin; - }, 0); - const unassignedPlatformsMaxEffort = unassigned.platforms.reduce((sum, p) => { - const platformMax = getMaxFTE(p.platform); - const workloadsMax = p.workloads.reduce((s, w) => s + getMaxFTE(w), 0); - return sum + platformMax + workloadsMax; - }, 0); - const unassignedMinEffort = unassignedRegularMinEffort + unassignedPlatformsMinEffort; - const unassignedMaxEffort = unassignedRegularMaxEffort + unassignedPlatformsMaxEffort; - - const unassignedPlatformsCount = unassigned.platforms.length; - const unassignedWorkloadsCount = unassigned.platforms.reduce((sum, p) => sum + p.workloads.length, 0); - const unassignedApplicationCount = unassigned.regular.length + unassignedPlatformsCount + unassignedWorkloadsCount; - - // Calculate governance model distribution for unassigned - const unassignedByGovernanceModel: Record = {}; - for (const app of unassigned.regular) { - const govModel = app.governanceModel?.name || 'Niet ingesteld'; - unassignedByGovernanceModel[govModel] = (unassignedByGovernanceModel[govModel] || 0) + 1; - } - for (const platformWithWorkloads of unassigned.platforms) { - const platform = platformWithWorkloads.platform; - const govModel = platform.governanceModel?.name || 'Niet ingesteld'; - unassignedByGovernanceModel[govModel] = (unassignedByGovernanceModel[govModel] || 0) + 1; - for (const workload of platformWithWorkloads.workloads) { - const workloadGovModel = workload.governanceModel?.name || 'Niet ingesteld'; - unassignedByGovernanceModel[workloadGovModel] = (unassignedByGovernanceModel[workloadGovModel] || 0) + 1; - } - } + // Calculate unassigned KPIs + const unassignedKPIs = calculateSubteamKPIs(unassignedData.regular, unassignedData.platforms); const result: TeamDashboardData = { - clusters, + teams, unassigned: { - applications: unassigned.regular, - platforms: unassigned.platforms, - totalEffort: unassignedTotalEffort, - minEffort: unassignedMinEffort, - maxEffort: unassignedMaxEffort, - applicationCount: unassignedApplicationCount, - byGovernanceModel: unassignedByGovernanceModel, + subteam: null, + applications: unassignedData.regular, + platforms: unassignedData.platforms, + ...unassignedKPIs, }, }; @@ -2096,8 +2277,9 @@ class JiraAssetsService { } catch (error) { logger.error('Failed to get team dashboard data', error); return { - clusters: [], + teams: [], unassigned: { + subteam: null, applications: [], platforms: [], totalEffort: 0, @@ -2109,6 +2291,293 @@ class JiraAssetsService { }; } } + + // CMDB Free-text search using Insight AM search API + async searchCMDB(query: string, limit: number = 10000): Promise<{ + metadata: { + count: number; + offset: number; + limit: number; + total: number; + criteria: { query: string; type: string; schema: number } | unknown; + }; + objectTypes: Array<{ + id: number; + name: string; + iconUrl?: string; + }>; + results: Array<{ + id: number; + key: string; + label: string; + objectTypeId: number; + avatarUrl?: string; + attributes: Array<{ + id: number; + name: string; + objectTypeAttributeId: number; + values: unknown[]; + }>; + }>; + }> { + try { + await this.detectApiType(); + + // Use Insight AM search API endpoint (different from IQL) + const searchUrl = `${config.jiraHost}/rest/insight-am/1/search?` + + `schema=${config.jiraSchemaId}&` + + `criteria=${encodeURIComponent(query)}&` + + `criteriaType=FREETEXT&` + + `attributes=Key,Object+Type,Label,Name,Description,Status&` + + `offset=0&limit=${limit}`; + + logger.info(`CMDB search: ${searchUrl}`); + + const response = await fetch(searchUrl, { + method: 'GET', + headers: this.headers, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Jira CMDB search error: ${response.status} - ${errorText}`); + } + + const data = await response.json() as { + results?: Array<{ + id: number; + key: string; + label: string; + objectTypeId: number; + avatarUrl?: string; + attributes?: Array<{ + id: number; + name: string; + objectTypeAttributeId: number; + values?: unknown[]; + }>; + }>; + metadata?: { + count: number; + offset: number; + limit: number; + total: number; + criteria: unknown; + }; + objectTypes?: Array<{ + id: number; + name: string; + iconUrl?: string; + }>; + }; + + // Transform the response to a cleaner format + // The API returns attributes with nested structure, we flatten the values + const transformedResults = (data.results || []).map((result) => ({ + id: result.id, + key: result.key, + label: result.label, + objectTypeId: result.objectTypeId, + avatarUrl: result.avatarUrl, + attributes: (result.attributes || []).map((attr) => ({ + id: attr.id, + name: attr.name, + objectTypeAttributeId: attr.objectTypeAttributeId, + values: attr.values || [], + })), + })); + + return { + metadata: data.metadata || { + count: transformedResults.length, + offset: 0, + limit, + total: transformedResults.length, + criteria: { query, type: 'FREETEXT', schema: parseInt(config.jiraSchemaId, 10) }, + }, + objectTypes: (data.objectTypes || []).map((ot) => ({ + id: ot.id, + name: ot.name, + iconUrl: ot.iconUrl, + })), + results: transformedResults, + }; + } catch (error) { + logger.error('CMDB search failed', error); + throw error; + } + } + + /** + * Query related objects using IQL (objects that reference the given application component) + * @param applicationKey - The key of the application component (e.g., "ICMT-123") + * @param objectType - The type of related objects to find (e.g., "Server", "Certificate") + * @param attributes - Optional list of attribute names to include + */ + async getRelatedObjects( + applicationKey: string, + objectType: string, + attributes?: string[] + ): Promise<{ + objects: Array<{ + id: number; + key: string; + name: string; + label: string; + attributes: Record; + }>; + total: number; + }> { + try { + await this.detectApiType(); + + // Build IQL query to find objects that reference this application component + const iqlQuery = `object having outboundReferences(Key = "${applicationKey}") AND objectType = "${objectType}"`; + + logger.info(`Querying related ${objectType} objects for ${applicationKey}`); + logger.debug(`IQL query: ${iqlQuery}`); + + let response: JiraAssetsSearchResponse; + + if (this.isDataCenter) { + const params = new URLSearchParams({ + iql: iqlQuery, + resultPerPage: '100', + includeAttributes: 'true', + includeAttributesDeep: '1', + objectSchemaId: config.jiraSchemaId, + }); + + response = await this.request( + `/iql/objects?${params.toString()}` + ); + } else { + response = await this.request( + '/aql/objects', + { + method: 'POST', + body: JSON.stringify({ + qlQuery: iqlQuery, + resultPerPage: 100, + includeAttributes: true, + }), + } + ); + } + + // Fetch attribute schema for this object type + let attrSchema: Map | undefined; + if (response.objectEntries.length > 0) { + const firstObj = response.objectEntries[0]; + const objectTypeId = firstObj.objectType?.id; + if (objectTypeId) { + if (this.attributeSchemaCache.has(objectType)) { + attrSchema = this.attributeSchemaCache.get(objectType); + } else { + attrSchema = await this.fetchAttributeSchema(objectTypeId); + this.attributeSchemaCache.set(objectType, attrSchema); + } + } + } + + // Transform objects to a simpler format + const objects = response.objectEntries.map(obj => { + const attrs: Record = {}; + + // Extract requested attributes (or all if none specified) + for (const attr of obj.attributes || []) { + let attrName = attr.objectTypeAttribute?.name; + if (!attrName && attrSchema) { + attrName = attrSchema.get(attr.objectTypeAttributeId); + } + if (!attrName) continue; + + // If specific attributes requested, only include those + if (attributes && attributes.length > 0) { + if (!attributes.includes(attrName)) continue; + } + + // Get the value + const values = attr.objectAttributeValues || []; + if (values.length > 0) { + const firstValue = values[0]; + if (firstValue.displayValue) { + attrs[attrName] = firstValue.displayValue; + } else if (firstValue.value !== undefined && firstValue.value !== null) { + attrs[attrName] = String(firstValue.value); + } else if (firstValue.referencedObject) { + attrs[attrName] = firstValue.referencedObject.label || firstValue.referencedObject.objectKey; + } else if (firstValue.status) { + attrs[attrName] = firstValue.status.name; + } else { + attrs[attrName] = null; + } + } else { + attrs[attrName] = null; + } + } + + return { + id: obj.id, + key: obj.objectKey, + name: obj.label || obj.objectKey, + label: obj.label, + attributes: attrs, + }; + }); + + logger.info(`Found ${objects.length} related ${objectType} objects for ${applicationKey}`); + + return { + objects, + total: response.objectEntries.length, + }; + } catch (error) { + logger.error(`Failed to get related ${objectType} objects for ${applicationKey}`, error); + throw error; + } + } + /** + * Invalidate the team dashboard cache + * Call this when an application is updated so the dashboard shows fresh data + */ + invalidateTeamDashboardCache(): void { + this.teamDashboardCache = null; + logger.info('Team dashboard cache invalidated'); + } + + /** + * Pre-warm the team dashboard cache in background + * This is called on server startup so users don't experience slow first load + */ + async preWarmTeamDashboardCache(): Promise { + try { + // Only pre-warm if cache is empty + if (this.teamDashboardCache) { + logger.info('Team dashboard cache already warm, skipping pre-warm'); + return; + } + + logger.info('Pre-warming team dashboard cache in background...'); + const startTime = Date.now(); + + // Fetch with default excluded statuses (which is what most users will see) + await this.getTeamDashboardData(['Closed', 'Deprecated']); + + const duration = Date.now() - startTime; + logger.info(`Team dashboard cache pre-warmed in ${duration}ms`); + } catch (error) { + logger.error('Failed to pre-warm team dashboard cache', error); + // Don't throw - pre-warming is optional + } + } } export const jiraAssetsService = new JiraAssetsService(); + +// Pre-warm team dashboard cache on startup (runs in background, doesn't block server start) +setTimeout(() => { + jiraAssetsService.preWarmTeamDashboardCache().catch(() => { + // Error already logged in the method + }); +}, 5000); // Wait 5 seconds after server start to avoid competing with other initialization diff --git a/backend/src/services/jiraAssetsClient.ts b/backend/src/services/jiraAssetsClient.ts new file mode 100644 index 0000000..96d6e4f --- /dev/null +++ b/backend/src/services/jiraAssetsClient.ts @@ -0,0 +1,425 @@ +/** + * JiraAssetsClient - Low-level Jira Assets API client for CMDB caching + * + * This client handles direct API calls to Jira Insight/Assets and provides + * methods for fetching, parsing, and updating CMDB objects. + */ + +import { config } from '../config/env.js'; +import { logger } from './logger.js'; +import { OBJECT_TYPES } from '../generated/jira-schema.js'; +import type { CMDBObject, CMDBObjectTypeName, ObjectReference } from '../generated/jira-types.js'; +import type { JiraAssetsObject, JiraAssetsAttribute, JiraAssetsSearchResponse } from '../types/index.js'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Error thrown when an object is not found in Jira (404) */ +export class JiraObjectNotFoundError extends Error { + constructor(public objectId: string) { + super(`Object ${objectId} not found in Jira`); + this.name = 'JiraObjectNotFoundError'; + } +} + +export interface JiraUpdatePayload { + objectTypeId?: number; // Optional for updates (PUT) - only needed for creates (POST) + attributes: Array<{ + objectTypeAttributeId: number; + objectAttributeValues: Array<{ value?: string }>; // value can be undefined when clearing + }>; +} + +// Map from Jira object type ID to our type name +const TYPE_ID_TO_NAME: Record = {}; +const JIRA_NAME_TO_TYPE: Record = {}; + +// Build lookup maps from schema +for (const [typeName, typeDef] of Object.entries(OBJECT_TYPES)) { + TYPE_ID_TO_NAME[typeDef.jiraTypeId] = typeName as CMDBObjectTypeName; + JIRA_NAME_TO_TYPE[typeDef.name] = typeName as CMDBObjectTypeName; +} + +// ============================================================================= +// JiraAssetsClient Implementation +// ============================================================================= + +class JiraAssetsClient { + private baseUrl: string; + private defaultHeaders: Record; + private isDataCenter: boolean | null = null; + private requestToken: string | null = null; + + constructor() { + this.baseUrl = `${config.jiraHost}/rest/insight/1.0`; + this.defaultHeaders = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + // Add PAT authentication if configured + if (config.jiraAuthMethod === 'pat' && config.jiraPat) { + this.defaultHeaders['Authorization'] = `Bearer ${config.jiraPat}`; + } + } + + // ========================================================================== + // Request Token Management (for user-context requests) + // ========================================================================== + + setRequestToken(token: string): void { + this.requestToken = token; + } + + clearRequestToken(): void { + this.requestToken = null; + } + + // ========================================================================== + // API Detection + // ========================================================================== + + private async detectApiType(): Promise { + if (this.isDataCenter !== null) return; + + // Detect based on host URL pattern: + // - Jira Cloud uses *.atlassian.net domains + // - Everything else (custom domains) is Data Center / on-premise + if (config.jiraHost.includes('atlassian.net')) { + this.isDataCenter = false; + logger.info('JiraAssetsClient: Detected Jira Cloud (Assets API) based on host URL'); + } else { + this.isDataCenter = true; + logger.info('JiraAssetsClient: Detected Jira Data Center (Insight API) based on host URL'); + } + } + + private getHeaders(): Record { + const headers = { ...this.defaultHeaders }; + + // Use request-scoped token if available (for user context) + if (this.requestToken) { + headers['Authorization'] = `Bearer ${this.requestToken}`; + } + + return headers; + } + + // ========================================================================== + // Core API Methods + // ========================================================================== + + private async request(endpoint: string, options: RequestInit = {}): Promise { + const url = `${this.baseUrl}${endpoint}`; + + logger.debug(`JiraAssetsClient: ${options.method || 'GET'} ${url}`); + + const response = await fetch(url, { + ...options, + headers: { + ...this.getHeaders(), + ...options.headers, + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Jira API error ${response.status}: ${text}`); + } + + return response.json() as Promise; + } + + // ========================================================================== + // Public API Methods + // ========================================================================== + + async testConnection(): Promise { + try { + await this.detectApiType(); + const response = await fetch(`${this.baseUrl}/objectschema/${config.jiraSchemaId}`, { + headers: this.getHeaders(), + }); + return response.ok; + } catch (error) { + logger.error('JiraAssetsClient: Connection test failed', error); + return false; + } + } + + async getObject(objectId: string): Promise { + try { + return await this.request(`/object/${objectId}`); + } catch (error) { + // Check if this is a 404 (object not found / deleted) + if (error instanceof Error && error.message.includes('404')) { + logger.info(`JiraAssetsClient: Object ${objectId} not found in Jira (likely deleted)`); + throw new JiraObjectNotFoundError(objectId); + } + logger.error(`JiraAssetsClient: Failed to get object ${objectId}`, error); + return null; + } + } + + async searchObjects( + iql: string, + page: number = 1, + pageSize: number = 50 + ): Promise<{ objects: JiraAssetsObject[]; totalCount: number; hasMore: boolean }> { + await this.detectApiType(); + + let response: JiraAssetsSearchResponse; + + if (this.isDataCenter) { + // Try modern AQL endpoint first + try { + const params = new URLSearchParams({ + qlQuery: iql, + page: page.toString(), + resultPerPage: pageSize.toString(), + includeAttributes: 'true', + includeAttributesDeep: '1', + objectSchemaId: config.jiraSchemaId, + }); + response = await this.request(`/aql/objects?${params.toString()}`); + } catch (error) { + // Fallback to deprecated IQL endpoint + logger.warn(`JiraAssetsClient: AQL endpoint failed, falling back to IQL: ${error}`); + const params = new URLSearchParams({ + iql, + page: page.toString(), + resultPerPage: pageSize.toString(), + includeAttributes: 'true', + includeAttributesDeep: '1', + objectSchemaId: config.jiraSchemaId, + }); + response = await this.request(`/iql/objects?${params.toString()}`); + } + } else { + // Jira Cloud uses POST for AQL + response = await this.request('/aql/objects', { + method: 'POST', + body: JSON.stringify({ + qlQuery: iql, + page, + resultPerPage: pageSize, + includeAttributes: true, + }), + }); + } + + const totalCount = response.totalFilterCount || response.totalCount || 0; + const hasMore = response.objectEntries.length === pageSize && page * pageSize < totalCount; + + return { + objects: response.objectEntries || [], + totalCount, + hasMore, + }; + } + + async getAllObjectsOfType( + typeName: CMDBObjectTypeName, + batchSize: number = 40 + ): Promise { + const typeDef = OBJECT_TYPES[typeName]; + if (!typeDef) { + logger.warn(`JiraAssetsClient: Unknown type ${typeName}`); + return []; + } + + const allObjects: JiraAssetsObject[] = []; + let page = 1; + let hasMore = true; + + while (hasMore) { + const iql = `objectType = "${typeDef.name}"`; + const result = await this.searchObjects(iql, page, batchSize); + allObjects.push(...result.objects); + hasMore = result.hasMore; + page++; + } + + logger.info(`JiraAssetsClient: Fetched ${allObjects.length} ${typeName} objects`); + return allObjects; + } + + async getUpdatedObjectsSince( + since: Date, + _batchSize: number = 40 + ): Promise { + await this.detectApiType(); + + // Jira Data Center's IQL doesn't support filtering by 'updated' attribute + if (this.isDataCenter) { + logger.debug('JiraAssetsClient: Incremental sync via IQL not supported on Data Center, skipping'); + return []; + } + + // For Jira Cloud, we could use updated >= "date" in IQL + const iql = `updated >= "${since.toISOString()}"`; + const result = await this.searchObjects(iql, 1, 1000); + return result.objects; + } + + async updateObject(objectId: string, payload: JiraUpdatePayload): Promise { + try { + logger.info(`JiraAssetsClient.updateObject: Sending update for object ${objectId}`, { + attributeCount: payload.attributes.length, + payload: JSON.stringify(payload, null, 2) + }); + + await this.request(`/object/${objectId}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); + + logger.info(`JiraAssetsClient.updateObject: Successfully updated object ${objectId}`); + return true; + } catch (error) { + logger.error(`JiraAssetsClient: Failed to update object ${objectId}`, error); + return false; + } + } + + // ========================================================================== + // Object Parsing + // ========================================================================== + + parseObject(jiraObj: JiraAssetsObject): T | null { + const typeId = jiraObj.objectType?.id; + const typeName = TYPE_ID_TO_NAME[typeId] || JIRA_NAME_TO_TYPE[jiraObj.objectType?.name]; + + if (!typeName) { + logger.warn(`JiraAssetsClient: Unknown object type: ${jiraObj.objectType?.name} (ID: ${typeId})`); + return null; + } + + const typeDef = OBJECT_TYPES[typeName]; + if (!typeDef) { + return null; + } + + const result: Record = { + id: jiraObj.id.toString(), + objectKey: jiraObj.objectKey, + label: jiraObj.label, + _objectType: typeName, + _jiraUpdatedAt: jiraObj.updated || new Date().toISOString(), + _jiraCreatedAt: jiraObj.created || new Date().toISOString(), + }; + + // Parse each attribute based on schema + for (const attrDef of typeDef.attributes) { + const jiraAttr = this.findAttribute(jiraObj.attributes, attrDef.jiraId, attrDef.name); + result[attrDef.fieldName] = this.parseAttributeValue(jiraAttr, attrDef); + } + + return result as T; + } + + private findAttribute( + attributes: JiraAssetsAttribute[], + jiraId: number, + name: string + ): JiraAssetsAttribute | undefined { + // Try by ID first + let attr = attributes.find(a => a.objectTypeAttributeId === jiraId); + if (attr) return attr; + + // Try by name + attr = attributes.find(a => + a.objectTypeAttribute?.name === name || + a.objectTypeAttribute?.name?.toLowerCase() === name.toLowerCase() + ); + + return attr; + } + + private parseAttributeValue( + jiraAttr: JiraAssetsAttribute | undefined, + attrDef: { type: string; isMultiple: boolean } + ): unknown { + if (!jiraAttr?.objectAttributeValues?.length) { + return attrDef.isMultiple ? [] : null; + } + + const values = jiraAttr.objectAttributeValues; + + switch (attrDef.type) { + case 'reference': { + const refs = values + .filter(v => v.referencedObject) + .map(v => ({ + objectId: v.referencedObject!.id.toString(), + objectKey: v.referencedObject!.objectKey, + label: v.referencedObject!.label, + } as ObjectReference)); + return attrDef.isMultiple ? refs : refs[0] || null; + } + + case 'text': + case 'textarea': + case 'url': + case 'email': + case 'select': + case 'user': { + const val = values[0]?.displayValue ?? values[0]?.value ?? null; + // Strip HTML if present + if (val && typeof val === 'string' && val.includes('<')) { + return this.stripHtml(val); + } + return val; + } + + case 'integer': { + const val = values[0]?.value; + return val ? parseInt(val, 10) : null; + } + + case 'float': { + const val = values[0]?.value; + return val ? parseFloat(val) : null; + } + + case 'boolean': { + const val = values[0]?.value; + return val === 'true' || val === 'Ja'; + } + + case 'date': + case 'datetime': { + return values[0]?.value ?? values[0]?.displayValue ?? null; + } + + case 'status': { + const statusVal = values[0]?.status; + if (statusVal) { + return statusVal.name || null; + } + return values[0]?.displayValue ?? values[0]?.value ?? null; + } + + default: + return values[0]?.displayValue ?? values[0]?.value ?? null; + } + } + + private stripHtml(html: string): string { + return html + .replace(/<[^>]*>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); + } +} + +// Export singleton instance +export const jiraAssetsClient = new JiraAssetsClient(); + diff --git a/backend/src/services/mockData.ts b/backend/src/services/mockData.ts index 1439bb7..1b2d273 100644 --- a/backend/src/services/mockData.ts +++ b/backend/src/services/mockData.ts @@ -36,7 +36,8 @@ const mockApplications: ApplicationDetails[] = [ complexityFactor: { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog' }, numberOfUsers: null, governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' }, - applicationCluster: null, + applicationSubteam: null, + applicationTeam: null, applicationType: null, platform: null, requiredEffortApplicationManagement: null, @@ -63,7 +64,8 @@ const mockApplications: ApplicationDetails[] = [ complexityFactor: { objectId: '3', key: 'CMP-3', name: '3 - Hoog' }, numberOfUsers: null, governanceModel: null, - applicationCluster: null, + applicationSubteam: null, + applicationTeam: null, applicationType: null, platform: null, requiredEffortApplicationManagement: null, @@ -90,7 +92,8 @@ const mockApplications: ApplicationDetails[] = [ complexityFactor: null, numberOfUsers: null, governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' }, - applicationCluster: null, + applicationSubteam: null, + applicationTeam: null, applicationType: null, platform: null, requiredEffortApplicationManagement: null, @@ -117,7 +120,8 @@ const mockApplications: ApplicationDetails[] = [ complexityFactor: { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog' }, numberOfUsers: null, governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' }, - applicationCluster: null, + applicationSubteam: null, + applicationTeam: null, applicationType: null, platform: null, requiredEffortApplicationManagement: null, @@ -144,7 +148,8 @@ const mockApplications: ApplicationDetails[] = [ complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' }, numberOfUsers: null, governanceModel: null, - applicationCluster: null, + applicationSubteam: null, + applicationTeam: null, applicationType: null, platform: null, requiredEffortApplicationManagement: null, @@ -171,7 +176,8 @@ const mockApplications: ApplicationDetails[] = [ complexityFactor: { objectId: '3', key: 'CMP-3', name: '3 - Hoog' }, numberOfUsers: { objectId: '7', key: 'USR-7', name: '> 15.000' }, governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' }, - applicationCluster: null, + applicationSubteam: null, + applicationTeam: null, applicationType: null, platform: null, requiredEffortApplicationManagement: null, @@ -198,7 +204,8 @@ const mockApplications: ApplicationDetails[] = [ complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' }, numberOfUsers: null, governanceModel: null, - applicationCluster: null, + applicationSubteam: null, + applicationTeam: null, applicationType: null, platform: null, requiredEffortApplicationManagement: null, @@ -225,7 +232,8 @@ const mockApplications: ApplicationDetails[] = [ complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' }, numberOfUsers: { objectId: '6', key: 'USR-6', name: '10.000 - 15.000' }, governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' }, - applicationCluster: null, + applicationSubteam: null, + applicationTeam: null, applicationType: null, platform: null, requiredEffortApplicationManagement: null, @@ -252,7 +260,8 @@ const mockApplications: ApplicationDetails[] = [ complexityFactor: { objectId: '1', key: 'CMP-1', name: '1 - Laag' }, numberOfUsers: { objectId: '4', key: 'USR-4', name: '2.000 - 5.000' }, governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' }, - applicationCluster: null, + applicationSubteam: null, + applicationTeam: null, applicationType: null, platform: null, requiredEffortApplicationManagement: null, @@ -279,7 +288,8 @@ const mockApplications: ApplicationDetails[] = [ complexityFactor: { objectId: '1', key: 'CMP-1', name: '1 - Laag' }, numberOfUsers: { objectId: '1', key: 'USR-1', name: '< 100' }, governanceModel: { objectId: 'D', key: 'GOV-D', name: 'Uitbesteed met Business-Regie' }, - applicationCluster: null, + applicationSubteam: null, + applicationTeam: null, applicationType: null, platform: null, requiredEffortApplicationManagement: null, @@ -347,10 +357,10 @@ const mockBusinessImpactAnalyses: ReferenceValue[] = [ { objectId: '9', key: 'BIA-9', name: 'BIA-2022-0089 (Klasse C)' }, ]; -const mockApplicationClusters: ReferenceValue[] = [ - { objectId: '1', key: 'CLUSTER-1', name: 'Zorgapplicaties' }, - { objectId: '2', key: 'CLUSTER-2', name: 'Bedrijfsvoering' }, - { objectId: '3', key: 'CLUSTER-3', name: 'Infrastructuur' }, +const mockApplicationSubteams: ReferenceValue[] = [ + { objectId: '1', key: 'SUBTEAM-1', name: 'Zorgapplicaties' }, + { objectId: '2', key: 'SUBTEAM-2', name: 'Bedrijfsvoering' }, + { objectId: '3', key: 'SUBTEAM-3', name: 'Infrastructuur' }, ]; const mockApplicationTypes: ReferenceValue[] = [ @@ -420,11 +430,11 @@ export class MockDataService { filtered = filtered.filter((app) => !!app.complexityFactor); } - // Apply applicationCluster filter - if (filters.applicationCluster === 'empty') { - filtered = filtered.filter((app) => !app.applicationCluster); - } else if (filters.applicationCluster === 'filled') { - filtered = filtered.filter((app) => !!app.applicationCluster); + // Apply applicationSubteam filter + if (filters.applicationSubteam === 'empty') { + filtered = filtered.filter((app) => !app.applicationSubteam); + } else if (filters.applicationSubteam === 'filled') { + filtered = filtered.filter((app) => !!app.applicationSubteam); } // Apply applicationType filter @@ -468,7 +478,8 @@ export class MockDataService { governanceModel: app.governanceModel, dynamicsFactor: app.dynamicsFactor, complexityFactor: app.complexityFactor, - applicationCluster: app.applicationCluster, + applicationSubteam: app.applicationSubteam, + applicationTeam: app.applicationTeam, applicationType: app.applicationType, platform: app.platform, requiredEffortApplicationManagement: effort, @@ -501,7 +512,8 @@ export class MockDataService { complexityFactor?: ReferenceValue; numberOfUsers?: ReferenceValue; governanceModel?: ReferenceValue; - applicationCluster?: ReferenceValue; + applicationSubteam?: ReferenceValue; + applicationTeam?: ReferenceValue; applicationType?: ReferenceValue; hostingType?: ReferenceValue; businessImpactAnalyse?: ReferenceValue; @@ -527,8 +539,11 @@ export class MockDataService { if (updates.governanceModel !== undefined) { app.governanceModel = updates.governanceModel; } - if (updates.applicationCluster !== undefined) { - app.applicationCluster = updates.applicationCluster; + if (updates.applicationSubteam !== undefined) { + app.applicationSubteam = updates.applicationSubteam; + } + if (updates.applicationTeam !== undefined) { + app.applicationTeam = updates.applicationTeam; } if (updates.applicationType !== undefined) { app.applicationType = updates.applicationType; @@ -539,12 +554,6 @@ export class MockDataService { if (updates.businessImpactAnalyse !== undefined) { app.businessImpactAnalyse = updates.businessImpactAnalyse; } - if (updates.applicationCluster !== undefined) { - app.applicationCluster = updates.applicationCluster; - } - if (updates.applicationType !== undefined) { - app.applicationType = updates.applicationType; - } return true; } @@ -601,7 +610,7 @@ export class MockDataService { return []; } - async getApplicationClusters(): Promise { + async getApplicationSubteams(): Promise { // Return empty for mock - in real implementation, this comes from Jira return []; } @@ -671,7 +680,8 @@ export class MockDataService { governanceModel: app.governanceModel, dynamicsFactor: app.dynamicsFactor, complexityFactor: app.complexityFactor, - applicationCluster: app.applicationCluster, + applicationSubteam: app.applicationSubteam, + applicationTeam: app.applicationTeam, applicationType: app.applicationType, platform: app.platform, requiredEffortApplicationManagement: app.requiredEffortApplicationManagement, @@ -726,8 +736,8 @@ export class MockDataService { }); } - // Group all applications (regular + platforms + workloads) by cluster - const clusterMap = new Map(); @@ -739,39 +749,39 @@ export class MockDataService { platforms: [], }; - // Group regular applications by cluster + // Group regular applications by subteam for (const app of regularApplications) { - if (app.applicationCluster) { - const clusterId = app.applicationCluster.objectId; - if (!clusterMap.has(clusterId)) { - clusterMap.set(clusterId, { regular: [], platforms: [] }); + if (app.applicationSubteam) { + const subteamId = app.applicationSubteam.objectId; + if (!subteamMap.has(subteamId)) { + subteamMap.set(subteamId, { regular: [], platforms: [] }); } - clusterMap.get(clusterId)!.regular.push(app); + subteamMap.get(subteamId)!.regular.push(app); } else { unassigned.regular.push(app); } } - // Group platforms by cluster + // Group platforms by subteam for (const platformWithWorkloads of platformsWithWorkloads) { const platform = platformWithWorkloads.platform; - if (platform.applicationCluster) { - const clusterId = platform.applicationCluster.objectId; - if (!clusterMap.has(clusterId)) { - clusterMap.set(clusterId, { regular: [], platforms: [] }); + if (platform.applicationSubteam) { + const subteamId = platform.applicationSubteam.objectId; + if (!subteamMap.has(subteamId)) { + subteamMap.set(subteamId, { regular: [], platforms: [] }); } - clusterMap.get(clusterId)!.platforms.push(platformWithWorkloads); + subteamMap.get(subteamId)!.platforms.push(platformWithWorkloads); } else { unassigned.platforms.push(platformWithWorkloads); } } - // Get all clusters - const allClusters = mockApplicationClusters; - const clusters = allClusters.map(cluster => { - const clusterData = clusterMap.get(cluster.objectId) || { regular: [], platforms: [] }; - const regularApps = clusterData.regular; - const platforms = clusterData.platforms; + // Build subteams from mock data + const allSubteams = mockApplicationSubteams; + const subteams: import('../types/index.js').TeamDashboardSubteam[] = allSubteams.map(subteamRef => { + const subteamData = subteamMap.get(subteamRef.objectId) || { regular: [], platforms: [] }; + const regularApps = subteamData.regular; + const platforms = subteamData.platforms; // Calculate total effort: regular apps + platforms (including their workloads) const regularEffort = regularApps.reduce((sum, app) => @@ -803,7 +813,7 @@ export class MockDataService { } return { - cluster, + subteam: subteamRef, applications: regularApps, platforms, totalEffort, @@ -812,7 +822,28 @@ export class MockDataService { applicationCount, byGovernanceModel, }; - }); + }).filter(s => s.applicationCount > 0); // Only include subteams with apps + + // Create a virtual team containing all subteams (since Team doesn't exist in mock data) + const virtualTeam: import('../types/index.js').TeamDashboardTeam = { + team: { + objectId: 'mock-team-1', + key: 'TEAM-1', + name: 'Mock Team', + teamType: 'Business', + }, + subteams, + totalEffort: subteams.reduce((sum, s) => sum + s.totalEffort, 0), + minEffort: subteams.reduce((sum, s) => sum + s.minEffort, 0), + maxEffort: subteams.reduce((sum, s) => sum + s.maxEffort, 0), + applicationCount: subteams.reduce((sum, s) => sum + s.applicationCount, 0), + byGovernanceModel: subteams.reduce((acc, s) => { + for (const [key, count] of Object.entries(s.byGovernanceModel)) { + acc[key] = (acc[key] || 0) + count; + } + return acc; + }, {} as Record), + }; // Calculate unassigned totals const unassignedRegularEffort = unassigned.regular.reduce((sum, app) => @@ -842,8 +873,9 @@ export class MockDataService { } return { - clusters, + teams: subteams.length > 0 ? [virtualTeam] : [], unassigned: { + subteam: null, applications: unassigned.regular, platforms: unassigned.platforms, totalEffort: unassignedTotalEffort, diff --git a/backend/src/services/syncEngine.ts b/backend/src/services/syncEngine.ts new file mode 100644 index 0000000..c5f6269 --- /dev/null +++ b/backend/src/services/syncEngine.ts @@ -0,0 +1,463 @@ +/** + * SyncEngine - Background synchronization service + * + * Handles: + * - Full sync at startup and periodically + * - Incremental sync every 30 seconds + * - Schema-driven sync for all object types + */ + +import { logger } from './logger.js'; +import { cacheStore } from './cacheStore.js'; +import { jiraAssetsClient, JiraObjectNotFoundError } from './jiraAssetsClient.js'; +import { OBJECT_TYPES, getObjectTypesBySyncPriority } from '../generated/jira-schema.js'; +import type { CMDBObject, CMDBObjectTypeName } from '../generated/jira-types.js'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface SyncStats { + objectType: string; + objectsProcessed: number; + relationsExtracted: number; + duration: number; +} + +export interface SyncResult { + success: boolean; + stats: SyncStats[]; + totalObjects: number; + totalRelations: number; + duration: number; + error?: string; +} + +export interface SyncEngineStatus { + isRunning: boolean; + isSyncing: boolean; + lastFullSync: string | null; + lastIncrementalSync: string | null; + nextIncrementalSync: string | null; + incrementalInterval: number; +} + +// ============================================================================= +// Configuration +// ============================================================================= + +const DEFAULT_INCREMENTAL_INTERVAL = 30_000; // 30 seconds +const DEFAULT_BATCH_SIZE = 50; + +// ============================================================================= +// Sync Engine Implementation +// ============================================================================= + +class SyncEngine { + private isRunning: boolean = false; + private isSyncing: boolean = false; // For full/incremental syncs + private syncingTypes: Set = new Set(); // Track which types are being synced + private incrementalTimer: NodeJS.Timeout | null = null; + private incrementalInterval: number; + private batchSize: number; + private lastIncrementalSync: Date | null = null; + + constructor() { + this.incrementalInterval = parseInt( + process.env.SYNC_INCREMENTAL_INTERVAL_MS || String(DEFAULT_INCREMENTAL_INTERVAL), + 10 + ); + this.batchSize = parseInt( + process.env.JIRA_API_BATCH_SIZE || String(DEFAULT_BATCH_SIZE), + 10 + ); + } + + // ========================================================================== + // Lifecycle + // ========================================================================== + + /** + * Initialize the sync engine + * Performs initial sync if cache is cold, then starts incremental sync + */ + async initialize(): Promise { + if (this.isRunning) { + logger.warn('SyncEngine: Already running'); + return; + } + + logger.info('SyncEngine: Initializing...'); + this.isRunning = true; + + // Check if we need a full sync + const stats = cacheStore.getStats(); + const lastFullSync = stats.lastFullSync; + const needsFullSync = !stats.isWarm || !lastFullSync || this.isStale(lastFullSync, 24 * 60 * 60 * 1000); + + if (needsFullSync) { + logger.info('SyncEngine: Cache is cold or stale, starting full sync in background...'); + // Run full sync in background (non-blocking) + this.fullSync().catch(err => { + logger.error('SyncEngine: Background full sync failed', err); + }); + } else { + logger.info('SyncEngine: Cache is warm, skipping initial full sync'); + } + + // Start incremental sync scheduler + this.startIncrementalSyncScheduler(); + + logger.info('SyncEngine: Initialized'); + } + + /** + * Stop the sync engine + */ + stop(): void { + logger.info('SyncEngine: Stopping...'); + this.isRunning = false; + + if (this.incrementalTimer) { + clearInterval(this.incrementalTimer); + this.incrementalTimer = null; + } + + logger.info('SyncEngine: Stopped'); + } + + /** + * Check if a timestamp is stale + */ + private isStale(timestamp: string, maxAgeMs: number): boolean { + const age = Date.now() - new Date(timestamp).getTime(); + return age > maxAgeMs; + } + + // ========================================================================== + // Full Sync + // ========================================================================== + + /** + * Perform a full sync of all object types + */ + async fullSync(): Promise { + if (this.isSyncing) { + logger.warn('SyncEngine: Sync already in progress'); + return { + success: false, + stats: [], + totalObjects: 0, + totalRelations: 0, + duration: 0, + error: 'Sync already in progress', + }; + } + + this.isSyncing = true; + const startTime = Date.now(); + const stats: SyncStats[] = []; + let totalObjects = 0; + let totalRelations = 0; + + logger.info('SyncEngine: Starting full sync...'); + + try { + // Get object types sorted by sync priority + const objectTypes = getObjectTypesBySyncPriority(); + + for (const typeDef of objectTypes) { + const typeStat = await this.syncObjectType(typeDef.typeName as CMDBObjectTypeName); + stats.push(typeStat); + totalObjects += typeStat.objectsProcessed; + totalRelations += typeStat.relationsExtracted; + } + + // Update sync metadata + const now = new Date().toISOString(); + cacheStore.setSyncMetadata('lastFullSync', now); + cacheStore.setSyncMetadata('lastIncrementalSync', now); + this.lastIncrementalSync = new Date(); + + const duration = Date.now() - startTime; + logger.info(`SyncEngine: Full sync complete. ${totalObjects} objects, ${totalRelations} relations in ${duration}ms`); + + return { + success: true, + stats, + totalObjects, + totalRelations, + duration, + }; + } catch (error) { + logger.error('SyncEngine: Full sync failed', error); + return { + success: false, + stats, + totalObjects, + totalRelations, + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } finally { + this.isSyncing = false; + } + } + + /** + * Sync a single object type + */ + private async syncObjectType(typeName: CMDBObjectTypeName): Promise { + const startTime = Date.now(); + let objectsProcessed = 0; + let relationsExtracted = 0; + + try { + const typeDef = OBJECT_TYPES[typeName]; + if (!typeDef) { + logger.warn(`SyncEngine: Unknown type ${typeName}`); + return { objectType: typeName, objectsProcessed: 0, relationsExtracted: 0, duration: 0 }; + } + + logger.debug(`SyncEngine: Syncing ${typeName}...`); + + // Fetch all objects from Jira + const jiraObjects = await jiraAssetsClient.getAllObjectsOfType(typeName, this.batchSize); + + // Parse and cache objects + const parsedObjects: CMDBObject[] = []; + + for (const jiraObj of jiraObjects) { + const parsed = jiraAssetsClient.parseObject(jiraObj); + if (parsed) { + parsedObjects.push(parsed); + } + } + + // Batch upsert to cache + if (parsedObjects.length > 0) { + cacheStore.batchUpsertObjects(typeName, parsedObjects); + objectsProcessed = parsedObjects.length; + + // Extract relations + for (const obj of parsedObjects) { + cacheStore.extractAndStoreRelations(typeName, obj); + relationsExtracted++; + } + } + + const duration = Date.now() - startTime; + logger.debug(`SyncEngine: Synced ${objectsProcessed} ${typeName} objects in ${duration}ms`); + + return { + objectType: typeName, + objectsProcessed, + relationsExtracted, + duration, + }; + } catch (error) { + logger.error(`SyncEngine: Failed to sync ${typeName}`, error); + return { + objectType: typeName, + objectsProcessed, + relationsExtracted, + duration: Date.now() - startTime, + }; + } + } + + // ========================================================================== + // Incremental Sync + // ========================================================================== + + /** + * Start the incremental sync scheduler + */ + private startIncrementalSyncScheduler(): void { + if (this.incrementalTimer) { + clearInterval(this.incrementalTimer); + } + + logger.info(`SyncEngine: Starting incremental sync scheduler (every ${this.incrementalInterval}ms)`); + + this.incrementalTimer = setInterval(() => { + if (!this.isSyncing && this.isRunning) { + this.incrementalSync().catch(err => { + logger.error('SyncEngine: Incremental sync failed', err); + }); + } + }, this.incrementalInterval); + } + + /** + * Perform an incremental sync (only updated objects) + * + * Note: On Jira Data Center, IQL-based incremental sync is not supported. + * We instead check if a periodic full sync is needed. + */ + async incrementalSync(): Promise<{ success: boolean; updatedCount: number }> { + if (this.isSyncing) { + return { success: false, updatedCount: 0 }; + } + + this.isSyncing = true; + + try { + // Get the last sync time + const lastSyncStr = cacheStore.getSyncMetadata('lastIncrementalSync'); + const since = lastSyncStr + ? new Date(lastSyncStr) + : new Date(Date.now() - 60000); // Default: last minute + + logger.debug(`SyncEngine: Incremental sync since ${since.toISOString()}`); + + // Fetch updated objects from Jira + const updatedObjects = await jiraAssetsClient.getUpdatedObjectsSince(since, this.batchSize); + + // If no objects returned (e.g., Data Center doesn't support IQL incremental sync), + // check if we should trigger a full sync instead + if (updatedObjects.length === 0) { + const lastFullSyncStr = cacheStore.getSyncMetadata('lastFullSync'); + if (lastFullSyncStr) { + const lastFullSync = new Date(lastFullSyncStr); + const fullSyncAge = Date.now() - lastFullSync.getTime(); + const FULL_SYNC_INTERVAL = 15 * 60 * 1000; // 15 minutes + + if (fullSyncAge > FULL_SYNC_INTERVAL) { + logger.info('SyncEngine: Triggering periodic full sync (incremental not available)'); + // Release the lock before calling fullSync + this.isSyncing = false; + await this.fullSync(); + return { success: true, updatedCount: 0 }; + } + } + + // Update timestamp even if no objects were synced + const now = new Date(); + cacheStore.setSyncMetadata('lastIncrementalSync', now.toISOString()); + this.lastIncrementalSync = now; + + return { success: true, updatedCount: 0 }; + } + + let updatedCount = 0; + + for (const jiraObj of updatedObjects) { + const parsed = jiraAssetsClient.parseObject(jiraObj); + if (parsed) { + const typeName = parsed._objectType as CMDBObjectTypeName; + cacheStore.upsertObject(typeName, parsed); + cacheStore.extractAndStoreRelations(typeName, parsed); + updatedCount++; + } + } + + // Update sync metadata + const now = new Date(); + cacheStore.setSyncMetadata('lastIncrementalSync', now.toISOString()); + this.lastIncrementalSync = now; + + if (updatedCount > 0) { + logger.info(`SyncEngine: Incremental sync updated ${updatedCount} objects`); + } + + return { success: true, updatedCount }; + } catch (error) { + logger.error('SyncEngine: Incremental sync failed', error); + return { success: false, updatedCount: 0 }; + } finally { + this.isSyncing = false; + } + } + + // ========================================================================== + // Manual Sync Triggers + // ========================================================================== + + /** + * Trigger a sync for a specific object type + * Allows concurrent syncs for different types, but blocks if: + * - A full sync is in progress + * - An incremental sync is in progress + * - This specific type is already being synced + */ + async syncType(typeName: CMDBObjectTypeName): Promise { + // Block if a full or incremental sync is running + if (this.isSyncing) { + throw new Error('Full or incremental sync already in progress'); + } + + // Block if this specific type is already being synced + if (this.syncingTypes.has(typeName)) { + throw new Error(`Sync already in progress for ${typeName}`); + } + + this.syncingTypes.add(typeName); + + try { + return await this.syncObjectType(typeName); + } finally { + this.syncingTypes.delete(typeName); + } + } + + /** + * Force sync a single object + * If the object was deleted from Jira, it will be removed from the local cache + */ + async syncObject(typeName: CMDBObjectTypeName, objectId: string): Promise { + try { + const jiraObj = await jiraAssetsClient.getObject(objectId); + if (!jiraObj) return false; + + const parsed = jiraAssetsClient.parseObject(jiraObj); + if (!parsed) return false; + + cacheStore.upsertObject(typeName, parsed); + cacheStore.extractAndStoreRelations(typeName, parsed); + + return true; + } catch (error) { + // If object was deleted from Jira, remove it from our cache + if (error instanceof JiraObjectNotFoundError) { + const deleted = cacheStore.deleteObject(typeName, objectId); + if (deleted) { + logger.info(`SyncEngine: Removed deleted object ${typeName}/${objectId} from cache`); + } + return false; + } + logger.error(`SyncEngine: Failed to sync object ${objectId}`, error); + return false; + } + } + + // ========================================================================== + // Status + // ========================================================================== + + /** + * Get current sync engine status + */ + getStatus(): SyncEngineStatus { + const stats = cacheStore.getStats(); + + let nextIncrementalSync: string | null = null; + if (this.isRunning && this.lastIncrementalSync) { + const nextTime = new Date(this.lastIncrementalSync.getTime() + this.incrementalInterval); + nextIncrementalSync = nextTime.toISOString(); + } + + return { + isRunning: this.isRunning, + isSyncing: this.isSyncing, + lastFullSync: stats.lastFullSync, + lastIncrementalSync: stats.lastIncrementalSync, + nextIncrementalSync, + incrementalInterval: this.incrementalInterval, + }; + } +} + +// Export singleton instance +export const syncEngine = new SyncEngine(); + diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 9a6803f..9427bd4 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -26,6 +26,7 @@ export interface ReferenceValue { remarks?: string; // Remarks attribute for Governance Model application?: string; // Application attribute for Governance Model indicators?: string; // Indicators attribute for Business Impact Analyse + teamType?: string; // Type attribute for Team objects (Business, Enabling, Staf) } // Application list item (summary view) @@ -38,7 +39,8 @@ export interface ApplicationListItem { governanceModel: ReferenceValue | null; dynamicsFactor: ReferenceValue | null; complexityFactor: ReferenceValue | null; - applicationCluster: ReferenceValue | null; + applicationSubteam: ReferenceValue | null; + applicationTeam: ReferenceValue | null; applicationType: ReferenceValue | null; platform: ReferenceValue | null; // Reference to parent Platform Application Component requiredEffortApplicationManagement: number | null; // Calculated field @@ -74,7 +76,8 @@ export interface ApplicationDetails { complexityFactor: ReferenceValue | null; numberOfUsers: ReferenceValue | null; governanceModel: ReferenceValue | null; - applicationCluster: ReferenceValue | null; + applicationSubteam: ReferenceValue | null; + applicationTeam: ReferenceValue | null; applicationType: ReferenceValue | null; platform: ReferenceValue | null; // Reference to parent Platform Application Component requiredEffortApplicationManagement: number | null; // Calculated field @@ -92,7 +95,7 @@ export interface SearchFilters { governanceModel?: 'all' | 'filled' | 'empty'; dynamicsFactor?: 'all' | 'filled' | 'empty'; complexityFactor?: 'all' | 'filled' | 'empty'; - applicationCluster?: 'all' | 'filled' | 'empty'; + applicationSubteam?: 'all' | 'filled' | 'empty' | string; // Can be 'all', 'empty', or a specific subteam name applicationType?: 'all' | 'filled' | 'empty'; organisation?: string; hostingType?: string; @@ -168,7 +171,8 @@ export interface PendingChanges { complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue }; numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue }; governanceModel?: { from: ReferenceValue | null; to: ReferenceValue }; - applicationCluster?: { from: ReferenceValue | null; to: ReferenceValue }; + applicationSubteam?: { from: ReferenceValue | null; to: ReferenceValue }; + applicationTeam?: { from: ReferenceValue | null; to: ReferenceValue }; applicationType?: { from: ReferenceValue | null; to: ReferenceValue }; } @@ -189,7 +193,8 @@ export interface ReferenceOptions { numberOfUsers: ReferenceValue[]; governanceModels: ReferenceValue[]; applicationFunctions: ReferenceValue[]; - applicationClusters: ReferenceValue[]; + applicationSubteams: ReferenceValue[]; + applicationTeams: ReferenceValue[]; applicationTypes: ReferenceValue[]; organisations: ReferenceValue[]; hostingTypes: ReferenceValue[]; @@ -297,6 +302,31 @@ export interface PlatformWithWorkloads { totalEffort: number; // platformEffort + workloadsEffort } +// Subteam level in team dashboard hierarchy +export interface TeamDashboardSubteam { + subteam: ReferenceValue | null; + applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload) + platforms: PlatformWithWorkloads[]; // Platforms with their workloads + totalEffort: number; // Sum of all applications + platforms + workloads + minEffort: number; // Sum of all minimum FTE values + maxEffort: number; // Sum of all maximum FTE values + applicationCount: number; // Count of all applications (including platforms and workloads) + byGovernanceModel: Record; // Distribution per governance model +} + +// Team level in team dashboard hierarchy (contains subteams) +export interface TeamDashboardTeam { + team: ReferenceValue | null; // team.teamType contains "Business", "Enabling", or "Staf" + subteams: TeamDashboardSubteam[]; + // Aggregated KPIs (sum of all subteams) + totalEffort: number; + minEffort: number; + maxEffort: number; + applicationCount: number; + byGovernanceModel: Record; +} + +// Legacy type for backward compatibility (deprecated) export interface TeamDashboardCluster { cluster: ReferenceValue | null; applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload) @@ -309,16 +339,8 @@ export interface TeamDashboardCluster { } export interface TeamDashboardData { - clusters: TeamDashboardCluster[]; - unassigned: { - applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload) - platforms: PlatformWithWorkloads[]; // Platforms with their workloads - totalEffort: number; // Sum of all applications + platforms + workloads - minEffort: number; // Sum of all minimum FTE values - maxEffort: number; // Sum of all maximum FTE values - applicationCount: number; // Count of all applications (including platforms and workloads) - byGovernanceModel: Record; // Distribution per governance model - }; + teams: TeamDashboardTeam[]; + unassigned: TeamDashboardSubteam; // Apps without team assignment } // Jira Assets API types @@ -347,6 +369,9 @@ export interface JiraAssetsAttribute { objectKey: string; label: string; }; + status?: { + name: string; + }; }>; } @@ -364,7 +389,8 @@ export interface ApplicationUpdateRequest { complexityFactor?: string; numberOfUsers?: string; governanceModel?: string; - applicationCluster?: string; + applicationSubteam?: string; + applicationTeam?: string; applicationType?: string; hostingType?: string; businessImpactAnalyse?: string; diff --git a/frontend/index.html b/frontend/index.html index 0cea64a..fa3a87a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - ZiRA Classificatie Tool - Zuyderland + CMDB Analyse Tool - Zuyderland
diff --git a/frontend/public/logo-zuyderland.svg b/frontend/public/logo-zuyderland.svg new file mode 100644 index 0000000..d585cd1 --- /dev/null +++ b/frontend/public/logo-zuyderland.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bdd945c..ec65730 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,14 +1,112 @@ -import { useEffect, useState } from 'react'; -import { Routes, Route, Link, useLocation } from 'react-router-dom'; +import { useEffect, useState, useRef } from 'react'; +import { Routes, Route, Link, useLocation, Navigate, useParams } from 'react-router-dom'; import { clsx } from 'clsx'; +import SearchDashboard from './components/SearchDashboard'; import Dashboard from './components/Dashboard'; import ApplicationList from './components/ApplicationList'; -import ApplicationDetail from './components/ApplicationDetail'; +import ApplicationInfo from './components/ApplicationInfo'; +import GovernanceModelHelper from './components/GovernanceModelHelper'; import TeamDashboard from './components/TeamDashboard'; import ConfigurationV25 from './components/ConfigurationV25'; +import ReportsDashboard from './components/ReportsDashboard'; +import GovernanceAnalysis from './components/GovernanceAnalysis'; +import DataModelDashboard from './components/DataModelDashboard'; +import FTECalculator from './components/FTECalculator'; import Login from './components/Login'; import { useAuthStore } from './stores/authStore'; +// Redirect component for old app-components/overview/:id paths +function RedirectToApplicationEdit() { + const { id } = useParams<{ id: string }>(); + return ; +} + +// Dropdown menu item type +interface NavItem { + path: string; + label: string; + exact?: boolean; +} + +interface NavDropdown { + label: string; + icon?: React.ReactNode; + items: NavItem[]; + basePath: string; +} + +// Dropdown component for navigation +function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive: boolean }) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const location = useLocation(); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Close dropdown on route change + useEffect(() => { + setIsOpen(false); + }, [location.pathname]); + + return ( +
+ + + {isOpen && ( +
+ {dropdown.items.map((item) => { + const itemActive = item.exact + ? location.pathname === item.path + : location.pathname.startsWith(item.path); + + return ( + + {item.label} + + ); + })} +
+ )} +
+ ); +} + function UserMenu() { const { user, authMethod, logout } = useAuthStore(); const [isOpen, setIsOpen] = useState(false); @@ -84,12 +182,32 @@ function UserMenu() { function AppContent() { const location = useLocation(); - const navItems = [ - { path: '/', label: 'Dashboard', exact: true }, - { path: '/applications', label: 'Applicaties', exact: false }, - { path: '/teams', label: 'Team-indeling', exact: true }, - { path: '/configuration', label: 'FTE Config v25', exact: true }, - ]; + // Navigation structure + const appComponentsDropdown: NavDropdown = { + label: 'Application Component', + basePath: '/application', + items: [ + { path: '/app-components', label: 'Dashboard', exact: true }, + { path: '/application/overview', label: 'Overzicht', exact: false }, + { path: '/application/fte-calculator', label: 'FTE Calculator', exact: true }, + { path: '/app-components/fte-config', label: 'FTE Config', exact: true }, + ], + }; + + const reportsDropdown: NavDropdown = { + label: 'Rapporten', + basePath: '/reports', + items: [ + { path: '/reports', label: 'Overzicht', exact: true }, + { path: '/reports/team-dashboard', label: 'Team-indeling', exact: true }, + { path: '/reports/governance-analysis', label: 'Analyse Regiemodel', exact: true }, + { path: '/reports/data-model', label: 'Datamodel', exact: true }, + ], + }; + + const isAppComponentsActive = location.pathname.startsWith('/app-components') || location.pathname.startsWith('/application'); + const isReportsActive = location.pathname.startsWith('/reports'); + const isDashboardActive = location.pathname === '/'; return (
@@ -98,39 +216,35 @@ function AppContent() {
-
-
- ZiRA -
+ + Zuyderland

- Classificatie Tool + Analyse Tool

Zuyderland CMDB

-
+ -
@@ -142,9 +256,30 @@ function AppContent() { {/* Main content */}
- } /> - } /> - } /> + {/* Main Dashboard (Search) */} + } /> + + {/* Application routes (new structure) */} + } /> + } /> + } /> + } /> + + {/* Application Component routes */} + } /> + } /> + + {/* Reports routes */} + } /> + } /> + } /> + } /> + + {/* Legacy redirects for bookmarks - redirect old paths to new ones */} + } /> + } /> + } /> + } /> } /> } /> @@ -178,12 +313,12 @@ function App() { } // Show login if OAuth is enabled and not authenticated - if (config?.oauthEnabled && !isAuthenticated) { + if (config?.authMethod === 'oauth' && !isAuthenticated) { return ; } // Show login if nothing is configured - if (!config?.oauthEnabled && !config?.serviceAccountEnabled) { + if (config?.authMethod === 'none') { return ; } diff --git a/frontend/src/components/ApplicationInfo.tsx b/frontend/src/components/ApplicationInfo.tsx new file mode 100644 index 0000000..9fe4da3 --- /dev/null +++ b/frontend/src/components/ApplicationInfo.tsx @@ -0,0 +1,620 @@ +import { useEffect, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { clsx } from 'clsx'; +import { + getApplicationById, + getConfig, + getRelatedObjects, + RelatedObject, +} from '../services/api'; +import { StatusBadge, BusinessImportanceBadge } from './ApplicationList'; +import { EffortDisplay } from './EffortDisplay'; +import { useEffortCalculation, getEffectiveFte } from '../hooks/useEffortCalculation'; +import type { ApplicationDetails } from '../types'; + +// Related objects configuration +interface RelatedObjectConfig { + objectType: string; + title: string; + icon: React.ReactNode; + attributes: string[]; + columns: { key: string; label: string; isName?: boolean }[]; + colorScheme: 'blue' | 'green' | 'orange' | 'purple' | 'cyan'; +} + +const RELATED_OBJECTS_CONFIG: RelatedObjectConfig[] = [ + { + objectType: 'Server', + title: 'Servers', + icon: ( + + + + ), + attributes: ['Name', 'Status', 'State'], + columns: [ + { key: 'Name', label: 'Naam', isName: true }, + { key: 'Status', label: 'Status' }, + { key: 'State', label: 'State' }, + ], + colorScheme: 'blue', + }, + { + objectType: 'AzureSubscription', + title: 'Azure Subscriptions', + icon: ( + + + + ), + attributes: ['Name', 'Status'], + columns: [ + { key: 'Name', label: 'Naam', isName: true }, + { key: 'Status', label: 'Status' }, + ], + colorScheme: 'cyan', + }, + { + objectType: 'Certificate', + title: 'Certificaten', + icon: ( + + + + ), + attributes: ['Name', 'Status', 'Expiry Date', 'Autorenew', 'Requester', 'Certificate Owner', 'IT Operations Team', 'Application Management'], + columns: [ + { key: 'Name', label: 'Naam', isName: true }, + { key: 'Status', label: 'Status' }, + { key: 'Expiry Date', label: 'Vervaldatum' }, + { key: 'Autorenew', label: 'Auto-renew' }, + { key: 'Certificate Owner', label: 'Eigenaar' }, + ], + colorScheme: 'orange', + }, + { + objectType: 'Connection', + title: 'Connecties', + icon: ( + + + + ), + attributes: ['Name', 'Source', 'Target', 'Type', 'Protocol'], + columns: [ + { key: 'Name', label: 'Naam', isName: true }, + { key: 'Source', label: 'Bron' }, + { key: 'Target', label: 'Doel' }, + { key: 'Type', label: 'Type' }, + { key: 'Protocol', label: 'Protocol' }, + ], + colorScheme: 'purple', + }, +]; + +const COLOR_SCHEMES = { + blue: { + header: 'bg-blue-50', + icon: 'text-blue-600', + badge: 'bg-blue-100 text-blue-700', + border: 'border-blue-200', + }, + green: { + header: 'bg-green-50', + icon: 'text-green-600', + badge: 'bg-green-100 text-green-700', + border: 'border-green-200', + }, + orange: { + header: 'bg-orange-50', + icon: 'text-orange-600', + badge: 'bg-orange-100 text-orange-700', + border: 'border-orange-200', + }, + purple: { + header: 'bg-purple-50', + icon: 'text-purple-600', + badge: 'bg-purple-100 text-purple-700', + border: 'border-purple-200', + }, + cyan: { + header: 'bg-cyan-50', + icon: 'text-cyan-600', + badge: 'bg-cyan-100 text-cyan-700', + border: 'border-cyan-200', + }, +}; + +export default function ApplicationInfo() { + const { id } = useParams<{ id: string }>(); + + const [application, setApplication] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [jiraHost, setJiraHost] = useState(''); + + // Use centralized effort calculation hook + const { calculatedFte, breakdown: effortBreakdown } = useEffortCalculation({ + application, + }); + + // Related objects state + const [relatedObjects, setRelatedObjects] = useState>(new Map()); + const [expandedSections, setExpandedSections] = useState>(new Set(['Server', 'Certificate'])); // Default expanded + + useEffect(() => { + async function fetchData() { + if (!id) return; + + setLoading(true); + setError(null); + + try { + const [app, config] = await Promise.all([ + getApplicationById(id), + getConfig(), + ]); + + setApplication(app); + setJiraHost(config.jiraHost); + // Note: Effort calculation is handled automatically by useEffortCalculation hook + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load application'); + } finally { + setLoading(false); + } + } + fetchData(); + }, [id]); + + // Set page title + useEffect(() => { + if (application) { + document.title = `${application.name} | Zuyderland CMDB`; + } + return () => { + document.title = 'Zuyderland CMDB'; + }; + }, [application]); + + // Fetch related objects when application is loaded + useEffect(() => { + if (!id || !application) return; + + // Initialize loading state for all object types + const initialState = new Map(); + RELATED_OBJECTS_CONFIG.forEach(config => { + initialState.set(config.objectType, { objects: [], loading: true, error: null }); + }); + setRelatedObjects(initialState); + + // Fetch each object type in parallel + RELATED_OBJECTS_CONFIG.forEach(async (config) => { + try { + const result = await getRelatedObjects(id, config.objectType, config.attributes); + setRelatedObjects(prev => { + const newMap = new Map(prev); + newMap.set(config.objectType, { objects: result?.objects || [], loading: false, error: null }); + return newMap; + }); + } catch (err) { + setRelatedObjects(prev => { + const newMap = new Map(prev); + newMap.set(config.objectType, { + objects: [], + loading: false, + error: err instanceof Error ? err.message : 'Failed to load' + }); + return newMap; + }); + } + }); + }, [id, application]); + + const toggleSection = (objectType: string) => { + setExpandedSections(prev => { + const newSet = new Set(prev); + if (newSet.has(objectType)) { + newSet.delete(objectType); + } else { + newSet.add(objectType); + } + return newSet; + }); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error || !application) { + return ( +
+ {error || 'Application not found'} +
+ ); + } + + return ( +
+ {/* Back navigation */} +
+ + + + + Terug naar overzicht + +
+ + {/* Header with application name and quick actions */} +
+
+
+
+

{application.name}

+ +
+
+ {application.key} + {application.applicationType && ( + + {application.applicationType.name} + + )} + {application.hostingType && ( + + {application.hostingType.name} + + )} +
+
+ + {/* Quick action buttons */} +
+ {jiraHost && application.key && ( + + + + + Open in Jira + + )} + + + + + Bewerken + +
+
+ + {/* Description */} + {application.description && ( +
+

{application.description}

+
+ )} +
+ + {/* Main info grid */} +
+ {/* Left column - Basic info */} +
+
+

Basis informatie

+
+
+ + + + {application.technischeArchitectuur && application.technischeArchitectuur.trim() !== '' && ( +
+ + + Document openen + + + + +
+ )} +
+
+ + {/* Right column - Business info */} +
+
+

Business informatie

+
+
+
+ + +
+ + + +
+
+
+ + {/* Management section */} +
+ {/* Governance */} +
+
+

Governance & Management

+
+
+ + + + + +
+
+ + {/* Contacts */} +
+
+

Contactpersonen

+
+
+ + + { + const primary = application.technicalApplicationManagementPrimary?.trim(); + const secondary = application.technicalApplicationManagementSecondary?.trim(); + const parts = []; + if (primary) parts.push(primary); + if (secondary) parts.push(secondary); + return parts.length > 0 ? parts.join(', ') : undefined; + })()} + /> +
+
+
+ + {/* Classification section */} +
+
+

Classificatie

+
+
+
+ + + +
+ + {/* FTE - Benodigde inspanning applicatiemanagement */} +
+ +
+ +
+

+ Automatisch berekend op basis van Regiemodel, Application Type, Business Impact Analyse en Hosting (v25) +

+
+
+
+ + {/* Application Functions */} + {application.applicationFunctions && application.applicationFunctions.length > 0 && ( +
+
+

+ Applicatiefuncties ({application.applicationFunctions.length}) +

+
+
+
+ {application.applicationFunctions.map((func, index) => ( + + {func.key} + {func.name} + + ))} +
+
+
+ )} + + {/* Related Objects Sections */} +
+

Gerelateerde objecten

+ + {RELATED_OBJECTS_CONFIG.map((config) => { + const data = relatedObjects.get(config.objectType); + const isExpanded = expandedSections.has(config.objectType); + const colors = COLOR_SCHEMES[config.colorScheme]; + const objects = data?.objects || []; + const count = objects.length; + const isLoading = data?.loading ?? true; + + return ( +
+ {/* Header - clickable to expand/collapse */} + + + {/* Content */} + {isExpanded && ( +
+ {isLoading ? ( +
+
+

Laden...

+
+ ) : data?.error ? ( +
+

{data.error}

+
+ ) : count === 0 ? ( +
+

Geen {config.title.toLowerCase()} gevonden

+
+ ) : ( +
+ + + + {config.columns.map((col) => ( + + ))} + + + + {objects.map((obj) => ( + + {config.columns.map((col) => ( + + ))} + + ))} + +
+ {col.label} +
+ {col.isName && jiraHost ? ( + + {obj.attributes[col.key] || obj.name || '-'} + + + + + ) : ( + obj.attributes[col.key] || '-' + )} +
+
+ )} +
+ )} +
+ ); + })} +
+ + {/* Call to action */} +
+
+
+

Classificatie aanpassen?

+

+ Bewerk applicatiefuncties, classificatie en regiemodel met AI-ondersteuning. +

+
+ + + + + Bewerken + +
+
+
+ ); +} + +// Helper component for displaying info rows +function InfoRow({ label, value }: { label: string; value?: string | null }) { + return ( +
+ +

{value || '-'}

+
+ ); +} diff --git a/frontend/src/components/ApplicationList.tsx b/frontend/src/components/ApplicationList.tsx index a496a4f..4ab0ba9 100644 --- a/frontend/src/components/ApplicationList.tsx +++ b/frontend/src/components/ApplicationList.tsx @@ -29,7 +29,7 @@ export default function ApplicationList() { setStatuses, setApplicationFunction, setGovernanceModel, - setApplicationCluster, + setApplicationSubteam, setApplicationType, setOrganisation, setHostingType, @@ -45,6 +45,7 @@ export default function ApplicationList() { const [organisations, setOrganisations] = useState([]); const [hostingTypes, setHostingTypes] = useState([]); const [businessImportanceOptions, setBusinessImportanceOptions] = useState([]); + const [applicationSubteams, setApplicationSubteams] = useState([]); const [showFilters, setShowFilters] = useState(true); // Sync URL params with store on mount @@ -98,6 +99,7 @@ export default function ApplicationList() { setOrganisations(data.organisations); setHostingTypes(data.hostingTypes); setBusinessImportanceOptions(data.businessImportance || []); + setApplicationSubteams(data.applicationSubteams || []); } catch (err) { console.error('Failed to load reference data', err); } @@ -126,7 +128,7 @@ export default function ApplicationList() { // Only navigate programmatically for regular clicks if (!event.ctrlKey && !event.metaKey && !event.shiftKey && event.button === 0) { event.preventDefault(); - navigate(`/applications/${app.id}`); + navigate(`/application/${app.id}`); } }; @@ -257,26 +259,6 @@ export default function ApplicationList() {
-
- -
- {(['all', 'filled', 'empty'] as const).map((value) => ( - - ))} -
-
-
@@ -347,6 +329,23 @@ export default function ApplicationList() { ))}
+ +
+ + +
@@ -405,7 +404,7 @@ export default function ApplicationList() { > handleRowClick(app, index, e)} className="block px-4 py-3 text-sm text-gray-500" > @@ -414,7 +413,7 @@ export default function ApplicationList() { handleRowClick(app, index, e)} className="block px-4 py-3" > @@ -426,7 +425,7 @@ export default function ApplicationList() { handleRowClick(app, index, e)} className="block px-4 py-3" > @@ -435,7 +434,7 @@ export default function ApplicationList() { handleRowClick(app, index, e)} className="block px-4 py-3" > @@ -460,7 +459,7 @@ export default function ApplicationList() { handleRowClick(app, index, e)} className="block px-4 py-3" > @@ -477,7 +476,7 @@ export default function ApplicationList() { handleRowClick(app, index, e)} className="block px-4 py-3 text-sm text-gray-900" > @@ -502,7 +501,7 @@ export default function ApplicationList() {
{currentPage > 1 ? ( setCurrentPage(currentPage - 1)} className="btn btn-secondary" > @@ -518,7 +517,7 @@ export default function ApplicationList() { {currentPage < result.totalPages ? ( setCurrentPage(currentPage + 1)} className="btn btn-secondary" > diff --git a/frontend/src/components/CacheStatusIndicator.tsx b/frontend/src/components/CacheStatusIndicator.tsx new file mode 100644 index 0000000..cf7923c --- /dev/null +++ b/frontend/src/components/CacheStatusIndicator.tsx @@ -0,0 +1,180 @@ +import React, { useState, useEffect } from 'react'; +import { getCacheStatus, triggerSync, type CacheStatus } from '../services/api'; + +interface CacheStatusIndicatorProps { + /** Show compact version (just icon + time) */ + compact?: boolean; + /** Auto-refresh interval in ms (default: 30000) */ + refreshInterval?: number; +} + +export const CacheStatusIndicator: React.FC = ({ + compact = false, + refreshInterval = 30000, +}) => { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [error, setError] = useState(null); + + const fetchStatus = async () => { + try { + const data = await getCacheStatus(); + setStatus(data); + setError(null); + } catch (err) { + setError('Kon status niet ophalen'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchStatus(); + const interval = setInterval(fetchStatus, refreshInterval); + return () => clearInterval(interval); + }, [refreshInterval]); + + const handleSync = async () => { + if (syncing) return; + setSyncing(true); + try { + await triggerSync(); + // Refetch status after a short delay + setTimeout(fetchStatus, 1000); + } catch (err) { + setError('Sync mislukt'); + } finally { + setSyncing(false); + } + }; + + if (loading) { + return ( +
+ + + + + {!compact && Laden...} +
+ ); + } + + if (error || !status) { + return ( +
+ + + + {!compact && {error || 'Geen data'}} +
+ ); + } + + const lastSync = status.cache.lastIncrementalSync; + const ageMinutes = lastSync + ? Math.floor((Date.now() - new Date(lastSync).getTime()) / 60000) + : null; + + const isWarm = status.cache.isWarm; + const isSyncing = status.sync.isSyncing || syncing; + + // Status color + const statusColor = !isWarm + ? 'text-amber-400' + : ageMinutes !== null && ageMinutes > 5 + ? 'text-amber-400' + : 'text-emerald-400'; + + if (compact) { + return ( + + ); + } + + return ( +
+
+

+ + + + Cache Status +

+ + {isWarm ? 'Actief' : 'Cold Start'} + +
+ +
+
+ Objecten in cache: + {status.cache.totalObjects.toLocaleString()} +
+
+ Relaties: + {status.cache.totalRelations.toLocaleString()} +
+
+ Laatst gesynchroniseerd: + + {ageMinutes !== null ? `${ageMinutes} min geleden` : 'Nooit'} + +
+ {status.sync.isSyncing && ( +
+ + + + + Synchronisatie bezig... +
+ )} +
+ + +
+ ); +}; + +export default CacheStatusIndicator; + diff --git a/frontend/src/components/ConflictDialog.tsx b/frontend/src/components/ConflictDialog.tsx new file mode 100644 index 0000000..c55e9ba --- /dev/null +++ b/frontend/src/components/ConflictDialog.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import type { ConflictError } from '../services/api'; + +interface ConflictDialogProps { + conflict: ConflictError; + onForceOverwrite: () => void; + onDiscard: () => void; + onClose: () => void; + isLoading?: boolean; +} + +export const ConflictDialog: React.FC = ({ + conflict, + onForceOverwrite, + onDiscard, + onClose, + isLoading = false, +}) => { + return ( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+
+ {/* Header */} +
+
+
+ + + +
+
+

+ Wijzigingsconflict Gedetecteerd +

+

+ {conflict.message} +

+
+
+
+ + {/* Content */} +
+ {conflict.warning && ( +

{conflict.warning}

+ )} + + {conflict.conflicts && conflict.conflicts.length > 0 && ( + <> +

+ De volgende velden zijn gewijzigd terwijl u aan het bewerken was: +

+ +
+ + + + + + + + + + {conflict.conflicts.map((c, index) => ( + + + + + + ))} + +
VeldUw waardeWaarde in Jira
+ {c.field} + + + {formatValue(c.proposedValue)} + + + + {formatValue(c.jiraValue)} + +
+
+ + )} + + {/* Info box */} +
+

+ Wat wilt u doen? +
+ • Doorvoeren: Uw wijzigingen overschrijven de huidige waarden in Jira +
+ • Verwerpen: Uw wijzigingen worden weggegooid en de huidige data wordt geladen +

+
+
+ + {/* Footer */} +
+ + +
+
+
+
+ ); +}; + +function formatValue(value: unknown): string { + if (value === null || value === undefined) { + return '(leeg)'; + } + + if (Array.isArray(value)) { + if (value.length === 0) return '(leeg)'; + return value.map(v => { + if (typeof v === 'object' && v && 'label' in v) { + return (v as { label: string }).label; + } + return String(v); + }).join(', '); + } + + if (typeof value === 'object' && value && 'label' in value) { + return (value as { label: string }).label; + } + + if (typeof value === 'boolean') { + return value ? 'Ja' : 'Nee'; + } + + return String(value); +} + +export default ConflictDialog; + diff --git a/frontend/src/components/CustomSelect.tsx b/frontend/src/components/CustomSelect.tsx index 4375aa0..a0dbe84 100644 --- a/frontend/src/components/CustomSelect.tsx +++ b/frontend/src/components/CustomSelect.tsx @@ -14,19 +14,32 @@ interface CustomSelectProps { // Helper function to get display text for an option function getDisplayText(option: ReferenceValue, showSummary: boolean, showRemarks: boolean): string | null { if (showRemarks) { - // Concatenate description and remarks with ". " + // Concatenate description, remarks, and indicators with ". " const parts: string[] = []; if (option.description) parts.push(option.description); if (option.remarks) parts.push(option.remarks); + if (option.indicators) parts.push(option.indicators); return parts.length > 0 ? parts.join('. ') : null; } if (showSummary && option.summary) { + // Include indicators if available + if (option.indicators) { + return `${option.summary}. ${option.indicators}`; + } return option.summary; } if (showSummary && !option.summary && option.description) { + // Include indicators if available + if (option.indicators) { + return `${option.description}. ${option.indicators}`; + } return option.description; } if (!showSummary && option.description) { + // Include indicators if available + if (option.indicators) { + return `${option.description}. ${option.indicators}`; + } return option.description; } return null; diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index c6e6d3c..21159aa 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; -import { getDashboardStats, getRecentClassifications } from '../services/api'; -import type { DashboardStats, ClassificationResult } from '../types'; +import { getDashboardStats, getRecentClassifications, getReferenceData } from '../services/api'; +import type { DashboardStats, ClassificationResult, ReferenceValue } from '../types'; // Extended type to include stale indicator from API interface DashboardStatsWithMeta extends DashboardStats { @@ -12,9 +12,36 @@ interface DashboardStatsWithMeta extends DashboardStats { export default function Dashboard() { const [stats, setStats] = useState(null); const [recentClassifications, setRecentClassifications] = useState([]); + const [governanceModels, setGovernanceModels] = useState([]); + const [hoveredGovModel, setHoveredGovModel] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); + const hoverTimeoutRef = useRef(null); + + // Hover handlers with delayed hide to prevent flickering when moving between badges + const handleGovModelMouseEnter = useCallback((hoverKey: string) => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + setHoveredGovModel(hoverKey); + }, []); + + const handleGovModelMouseLeave = useCallback(() => { + hoverTimeoutRef.current = setTimeout(() => { + setHoveredGovModel(null); + }, 100); // Small delay to allow moving to another badge + }, []); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + }; + }, []); const fetchData = useCallback(async (forceRefresh: boolean = false) => { if (forceRefresh) { @@ -25,12 +52,14 @@ export default function Dashboard() { setError(null); try { - const [statsData, recentData] = await Promise.all([ + const [statsData, recentData, refData] = await Promise.all([ getDashboardStats(forceRefresh), getRecentClassifications(10), + getReferenceData(), ]); setStats(statsData as DashboardStatsWithMeta); setRecentClassifications(recentData); + setGovernanceModels(refData.governanceModels); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load dashboard'); } finally { @@ -104,7 +133,7 @@ export default function Dashboard() { {refreshing ? 'Laden...' : 'Ververs'} - + Start classificeren
@@ -141,23 +170,42 @@ export default function Dashboard() {
- {/* Progress bar */} + {/* Progress bars */}

Classificatie voortgang

-
-
- ApplicationFunction ingevuld - - {stats?.classifiedCount || 0} / {stats?.totalApplications || 0} - +
+ {/* ICT Governance Model Progress */} +
+
+ ICT Governance Model ingevuld + + {stats?.classifiedCount || 0} / {stats?.totalApplications || 0} ({progressPercentage}%) + +
+
+
+
-
-
+ + {/* ApplicationFunction Progress */} +
+
+ ApplicationFunction ingevuld + + {stats?.withApplicationFunction || 0} / {stats?.totalApplications || 0} ({stats?.applicationFunctionPercentage || 0}%) + +
+
+
+
@@ -186,7 +234,7 @@ export default function Dashboard() {
@@ -200,37 +248,110 @@ export default function Dashboard() {
{/* Governance model distribution */} -
-

+
+

Verdeling per regiemodel +

-
+
{stats?.byGovernanceModel && - Object.entries(stats.byGovernanceModel) - .sort((a, b) => { - // Sort alphabetically, but put "Niet ingesteld" at the end - if (a[0] === 'Niet ingesteld') return 1; - if (b[0] === 'Niet ingesteld') return -1; - return a[0].localeCompare(b[0], 'nl', { sensitivity: 'base' }); - }) - .map(([model, count]) => ( -
- {model} -
-
-
+ [ + ...governanceModels + .map(g => g.name) + .sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })), + 'Niet ingesteld' + ] + .filter(govModel => stats.byGovernanceModel[govModel] !== undefined || govModel === 'Niet ingesteld') + .map((govModel) => { + const count = stats.byGovernanceModel[govModel] || 0; + const colors = (() => { + if (govModel.includes('Regiemodel A')) return { bg: '#20556B', text: '#FFFFFF' }; + if (govModel.includes('Regiemodel B+') || govModel.includes('B+')) return { bg: '#286B86', text: '#FFFFFF' }; + if (govModel.includes('Regiemodel B')) return { bg: '#286B86', text: '#FFFFFF' }; + if (govModel.includes('Regiemodel C')) return { bg: '#81CBF2', text: '#20556B' }; + if (govModel.includes('Regiemodel D')) return { bg: '#F5A733', text: '#FFFFFF' }; + if (govModel.includes('Regiemodel E')) return { bg: '#E95053', text: '#FFFFFF' }; + if (govModel === 'Niet ingesteld') return { bg: '#E5E7EB', text: '#9CA3AF' }; + return { bg: '#6B7280', text: '#FFFFFF' }; + })(); + const shortLabel = govModel === 'Niet ingesteld' + ? '?' + : (govModel.match(/Regiemodel\s+(.+)/i)?.[1] || govModel.charAt(0)); + const govModelData = governanceModels.find(g => g.name === govModel); + const isHovered = hoveredGovModel === govModel; + + return ( +
handleGovModelMouseEnter(govModel)} + onMouseLeave={handleGovModelMouseLeave} + > +
+ {shortLabel}
- +
{count} - +
+ + {/* Hover popup */} + {isHovered && govModel !== 'Niet ingesteld' && ( +
+ {/* Arrow pointer */} +
+ + {/* Header: Summary (Description) */} +
+ {govModelData?.summary || govModel} + {govModelData?.description && ( + ({govModelData.description}) + )} +
+ + {/* Remarks */} + {govModelData?.remarks && ( +
+ {govModelData.remarks} +
+ )} + + {/* Application section */} + {govModelData?.application && ( +
+
+ Toepassing +
+
+ {govModelData.application} +
+
+ )} + + {/* Fallback message if no data */} + {!govModelData && ( +
+ Geen aanvullende informatie beschikbaar +
+ )} +
+ )}
-
- ))} + ); + })} {(!stats?.byGovernanceModel || Object.keys(stats.byGovernanceModel).length === 0) && (

Geen data beschikbaar

diff --git a/frontend/src/components/DataModelDashboard.tsx b/frontend/src/components/DataModelDashboard.tsx new file mode 100644 index 0000000..839b27b --- /dev/null +++ b/frontend/src/components/DataModelDashboard.tsx @@ -0,0 +1,648 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { getSchema, triggerTypeSync, type SchemaResponse, type SchemaObjectTypeDefinition, type SchemaAttributeDefinition } from '../services/api'; + +// Attribute type badge colors +const typeColors: Record = { + text: { bg: 'bg-gray-100', text: 'text-gray-700' }, + integer: { bg: 'bg-blue-100', text: 'text-blue-700' }, + float: { bg: 'bg-blue-100', text: 'text-blue-700' }, + boolean: { bg: 'bg-purple-100', text: 'text-purple-700' }, + date: { bg: 'bg-yellow-100', text: 'text-yellow-700' }, + datetime: { bg: 'bg-yellow-100', text: 'text-yellow-700' }, + select: { bg: 'bg-green-100', text: 'text-green-700' }, + reference: { bg: 'bg-orange-100', text: 'text-orange-700' }, + url: { bg: 'bg-cyan-100', text: 'text-cyan-700' }, + email: { bg: 'bg-cyan-100', text: 'text-cyan-700' }, + textarea: { bg: 'bg-gray-100', text: 'text-gray-700' }, + user: { bg: 'bg-pink-100', text: 'text-pink-700' }, + status: { bg: 'bg-red-100', text: 'text-red-700' }, + unknown: { bg: 'bg-gray-100', text: 'text-gray-500' }, +}; + +function TypeBadge({ type }: { type: string }) { + const colors = typeColors[type] || typeColors.unknown; + return ( + + {type} + + ); +} + +function AttributeRow({ attr, onReferenceClick }: { attr: SchemaAttributeDefinition; onReferenceClick?: (typeName: string) => void }) { + return ( + + + {attr.name} + {attr.isRequired && *} + + + {attr.fieldName} + + + + {attr.isMultiple && ( + [] + )} + + + {attr.referenceTypeName ? ( + + ) : ( + + )} + + + {attr.description || '—'} + + +
+ {attr.isSystem && ( + + SYS + + )} + {!attr.isEditable && !attr.isSystem && ( + + RO + + )} +
+ + + ); +} + +function ObjectTypeCard({ + objectType, + isExpanded, + onToggle, + onReferenceClick, + onRefresh, + isRefreshing, + refreshedCount, + refreshError, +}: { + objectType: SchemaObjectTypeDefinition; + isExpanded: boolean; + onToggle: () => void; + onReferenceClick: (typeName: string) => void; + onRefresh: (typeName: string) => void; + isRefreshing: boolean; + refreshedCount?: number; + refreshError?: string; +}) { + const referenceAttrs = objectType.attributes.filter(a => a.type === 'reference'); + const nonReferenceAttrs = objectType.attributes.filter(a => a.type !== 'reference'); + + // Use refreshed count if available, otherwise use the original objectCount + const displayCount = refreshedCount ?? objectType.objectCount; + + return ( +
+ {/* Header */} +
+
+
+
+ + + +
+
+

{objectType.name}

+

{objectType.typeName}

+
+
+
+
+
+ {displayCount.toLocaleString()} objects +
+
+ {objectType.attributes.length} attributes +
+
+
+ {objectType.incomingLinks.length > 0 && ( + + ← {objectType.incomingLinks.length} + + )} + {objectType.outgoingLinks.length > 0 && ( + + → {objectType.outgoingLinks.length} + + )} +
+ {/* Refresh button */} + + + + +
+
+
+ + {/* Error message */} + {refreshError && ( +
+
+ + + + {refreshError} +
+
+ )} + + {/* Expanded Content */} + {isExpanded && ( +
+ {/* Links Section */} + {(objectType.incomingLinks.length > 0 || objectType.outgoingLinks.length > 0) && ( +
+

+ Relationships +

+
+ {/* Incoming Links */} + {objectType.incomingLinks.length > 0 && ( +
+
+ + + + Referenced by ({objectType.incomingLinks.length}) +
+
+ {objectType.incomingLinks.map((link, idx) => ( + + ))} +
+
+ )} + + {/* Outgoing Links */} + {objectType.outgoingLinks.length > 0 && ( +
+
+ + + + References ({objectType.outgoingLinks.length}) +
+
+ {objectType.outgoingLinks.map((link, idx) => ( + + ))} +
+
+ )} +
+
+ )} + + {/* Attributes Table */} +
+ + + + + + + + + + + + + {/* Reference attributes first */} + {referenceAttrs.map((attr) => ( + + ))} + {/* Then non-reference attributes */} + {nonReferenceAttrs.map((attr) => ( + + ))} + +
+ Name + + Field + + Type + + Reference + + Description + + Flags +
+
+
+ )} +
+ ); +} + +export default function DataModelDashboard() { + const [schema, setSchema] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [expandedTypes, setExpandedTypes] = useState>(new Set()); + const [sortBy, setSortBy] = useState<'name' | 'objects' | 'attributes' | 'priority'>('priority'); + const [refreshingTypes, setRefreshingTypes] = useState>(new Set()); + const [refreshedCounts, setRefreshedCounts] = useState>({}); + const [refreshErrors, setRefreshErrors] = useState>({}); + + useEffect(() => { + loadSchema(); + }, []); + + async function loadSchema() { + try { + setLoading(true); + setError(null); + const data = await getSchema(); + setSchema(data); + // Reset refreshed counts when schema is reloaded + setRefreshedCounts({}); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load schema'); + } finally { + setLoading(false); + } + } + + const handleRefreshType = useCallback(async (typeName: string) => { + // Add to refreshing set and clear any previous error + setRefreshingTypes((prev) => new Set(prev).add(typeName)); + setRefreshErrors((prev) => { + const next = { ...prev }; + delete next[typeName]; + return next; + }); + + try { + const result = await triggerTypeSync(typeName); + + // Update the count for this type + if (result.stats?.objectsProcessed !== undefined) { + setRefreshedCounts((prev) => ({ + ...prev, + [typeName]: result.stats.objectsProcessed, + })); + } + } catch (err) { + console.error(`Failed to refresh ${typeName}:`, err); + // Extract error message + let errorMessage = 'Failed to sync object type'; + if (err instanceof Error) { + errorMessage = err.message; + } else if (typeof err === 'object' && err !== null && 'error' in err) { + errorMessage = String(err.error); + } + setRefreshErrors((prev) => ({ + ...prev, + [typeName]: errorMessage, + })); + } finally { + // Remove from refreshing set + setRefreshingTypes((prev) => { + const next = new Set(prev); + next.delete(typeName); + return next; + }); + } + }, []); + + const filteredAndSortedTypes = useMemo(() => { + if (!schema) return []; + + let types = Object.values(schema.objectTypes); + + // Filter by search query + if (searchQuery) { + const query = searchQuery.toLowerCase(); + types = types.filter( + (t) => + t.name.toLowerCase().includes(query) || + t.typeName.toLowerCase().includes(query) || + t.attributes.some( + (a) => + a.name.toLowerCase().includes(query) || + a.fieldName.toLowerCase().includes(query) + ) + ); + } + + // Sort + switch (sortBy) { + case 'name': + types.sort((a, b) => a.name.localeCompare(b.name)); + break; + case 'objects': + types.sort((a, b) => b.objectCount - a.objectCount); + break; + case 'attributes': + types.sort((a, b) => b.attributes.length - a.attributes.length); + break; + case 'priority': + types.sort((a, b) => a.syncPriority - b.syncPriority || a.name.localeCompare(b.name)); + break; + } + + return types; + }, [schema, searchQuery, sortBy]); + + const toggleExpanded = (typeName: string) => { + setExpandedTypes((prev) => { + const next = new Set(prev); + if (next.has(typeName)) { + next.delete(typeName); + } else { + next.add(typeName); + } + return next; + }); + }; + + const handleReferenceClick = (typeName: string) => { + // Expand the referenced type and scroll to it + setExpandedTypes((prev) => new Set(prev).add(typeName)); + + // Find and scroll to the element + setTimeout(() => { + const element = document.getElementById(`object-type-${typeName}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, 100); + }; + + const expandAll = () => { + setExpandedTypes(new Set(filteredAndSortedTypes.map((t) => t.typeName))); + }; + + const collapseAll = () => { + setExpandedTypes(new Set()); + }; + + if (loading) { + return ( +
+
+
+

Laden van datamodel...

+
+
+ ); + } + + if (error) { + return ( +
+ + + +

Fout bij laden

+

{error}

+ +
+ ); + } + + if (!schema) return null; + + return ( +
+ {/* Header */} +
+

Datamodel

+

+ Overzicht van alle object types, attributen en relaties in het Jira Assets schema. +

+
+ + {/* Stats Cards */} +
+
+
+
+ + + +
+
+
{schema.metadata.objectTypeCount}
+
Object Types
+
+
+
+
+
+
+ + + +
+
+
{schema.metadata.totalAttributes}
+
Attributen
+
+
+
+
+
+
+ + + +
+
+
+ {Object.values(schema.objectTypes).reduce((sum, t) => sum + t.outgoingLinks.length, 0)} +
+
Relaties
+
+
+
+
+
+
+ + + +
+
+
+ {new Date(schema.metadata.generatedAt).toLocaleDateString('nl-NL', { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} +
+
Gegenereerd
+
+
+
+
+ + {/* Toolbar */} +
+
+ {/* Search */} +
+
+ + + + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+ + {/* Sort */} +
+ Sorteren op: + +
+ + {/* Expand/Collapse */} +
+ + +
+
+
+ + {/* Results count */} +
+ {filteredAndSortedTypes.length} object types + {searchQuery && ` gevonden voor "${searchQuery}"`} +
+ + {/* Object Types List */} +
+ {filteredAndSortedTypes.map((objectType) => ( +
+ toggleExpanded(objectType.typeName)} + onReferenceClick={handleReferenceClick} + onRefresh={handleRefreshType} + isRefreshing={refreshingTypes.has(objectType.typeName)} + refreshedCount={refreshedCounts[objectType.typeName]} + refreshError={refreshErrors[objectType.typeName]} + /> +
+ ))} +
+ + {filteredAndSortedTypes.length === 0 && ( +
+ + + +

Geen object types gevonden voor "{searchQuery}"

+
+ )} +
+ ); +} diff --git a/frontend/src/components/EffortDisplay.tsx b/frontend/src/components/EffortDisplay.tsx new file mode 100644 index 0000000..f77e371 --- /dev/null +++ b/frontend/src/components/EffortDisplay.tsx @@ -0,0 +1,258 @@ +/** + * EffortDisplay - Shared component for displaying FTE effort calculations + * + * Used in: + * - ApplicationInfo.tsx (detail page) + * - GovernanceModelHelper.tsx (governance helper page) + */ + +import type { EffortCalculationBreakdown } from '../types'; + +export interface EffortDisplayProps { + /** The effective FTE value (after override if applicable) */ + effectiveFte: number | null; + /** The calculated FTE value (before override) */ + calculatedFte?: number | null; + /** Override FTE value if set */ + overrideFte?: number | null; + /** Full breakdown from effort calculation */ + breakdown?: EffortCalculationBreakdown | null; + /** Whether this is a preview (unsaved changes) */ + isPreview?: boolean; + /** Show full details (expanded view) */ + showDetails?: boolean; + /** Show override input field */ + showOverrideInput?: boolean; + /** Callback when override value changes */ + onOverrideChange?: (value: number | null) => void; +} + +// Display-only constants (for showing the formula, NOT for calculation) +// The actual calculation happens in backend/src/services/effortCalculation.ts +const HOURS_PER_WEEK_DISPLAY = 36; +const WORK_WEEKS_PER_YEAR_DISPLAY = 46; +const DECLARABLE_PERCENTAGE_DISPLAY = 0.75; + +export function EffortDisplay({ + effectiveFte, + calculatedFte, + overrideFte, + breakdown, + isPreview = false, + showDetails = true, + showOverrideInput = false, + onOverrideChange, +}: EffortDisplayProps) { + const hasOverride = overrideFte !== null && overrideFte !== undefined; + const hasBreakdown = breakdown !== null && breakdown !== undefined; + + // Extract breakdown values + const baseEffort = breakdown?.baseEffort ?? null; + const baseEffortMin = breakdown?.baseEffortMin ?? null; + const baseEffortMax = breakdown?.baseEffortMax ?? null; + + const numberOfUsersFactor = breakdown?.numberOfUsersFactor ?? { value: 1.0, name: null }; + const dynamicsFactor = breakdown?.dynamicsFactor ?? { value: 1.0, name: null }; + const complexityFactor = breakdown?.complexityFactor ?? { value: 1.0, name: null }; + + const governanceModelName = breakdown?.governanceModelName ?? breakdown?.governanceModel ?? null; + const applicationTypeName = breakdown?.applicationType ?? null; + const businessImpactAnalyse = breakdown?.businessImpactAnalyse ?? null; + const applicationManagementHosting = breakdown?.applicationManagementHosting ?? null; + + const warnings = breakdown?.warnings ?? []; + const errors = breakdown?.errors ?? []; + const usedDefaults = breakdown?.usedDefaults ?? []; + const requiresManualAssessment = breakdown?.requiresManualAssessment ?? false; + const isFixedFte = breakdown?.isFixedFte ?? false; + + // Use hours from backend breakdown (calculated in effortCalculation.ts) + // Only fall back to local calculation if breakdown is not available + const declarableHoursPerYear = breakdown?.hoursPerYear ?? (effectiveFte !== null + ? HOURS_PER_WEEK_DISPLAY * WORK_WEEKS_PER_YEAR_DISPLAY * effectiveFte * DECLARABLE_PERCENTAGE_DISPLAY + : 0); + const hoursPerMonth = breakdown?.hoursPerMonth ?? declarableHoursPerYear / 12; + const hoursPerWeekCalculated = breakdown?.hoursPerWeek ?? declarableHoursPerYear / WORK_WEEKS_PER_YEAR_DISPLAY; + const minutesPerWeek = hoursPerWeekCalculated * 60; + // For display of netto hours (before declarable percentage) + const netHoursPerYear = effectiveFte !== null + ? HOURS_PER_WEEK_DISPLAY * WORK_WEEKS_PER_YEAR_DISPLAY * effectiveFte + : 0; + + // No effort calculated + if (effectiveFte === null || effectiveFte === undefined) { + if (errors.length > 0) { + return ( +
+
+ {errors.map((error, i) => ( +
+ + {error} +
+ ))} +
+ Niet berekend - configuratie onvolledig +
+ ); + } + return Niet berekend; + } + + return ( +
+ {/* Errors */} + {errors.length > 0 && ( +
+ {errors.map((error, i) => ( +
+ + {error} +
+ ))} +
+ )} + + {/* Warnings */} + {warnings.length > 0 && ( +
+ {warnings.map((warning, i) => ( +
+ {warning.startsWith('⚠️') || warning.startsWith('ℹ️') ? '' : 'ℹ️'} + {warning} +
+ ))} +
+ )} + + {/* Main FTE display */} +
+ {effectiveFte.toFixed(2)} FTE + {hasOverride && ( + (Override) + )} + {isPreview && !hasOverride && ( + (voorvertoning) + )} + {isFixedFte && ( + (vast) + )} + {requiresManualAssessment && ( + (handmatige beoordeling) + )} +
+ + {/* Show calculated value if override is active */} + {hasOverride && calculatedFte !== null && calculatedFte !== undefined && ( +
+ Berekende waarde: {calculatedFte.toFixed(2)} FTE +
+ )} + + {/* Override input */} + {showOverrideInput && onOverrideChange && ( +
+ + { + const value = e.target.value; + if (value === '') { + onOverrideChange(null); + } else { + const numValue = parseFloat(value); + if (!isNaN(numValue)) { + onOverrideChange(numValue); + } + } + }} + className="w-24 px-2 py-1 border border-gray-300 rounded text-sm" + placeholder="Leeg" + /> +
+ )} + + {showDetails && baseEffort !== null && ( +
+ {/* Base FTE with range */} +
+ Basis FTE: {baseEffort.toFixed(2)} FTE + {baseEffortMin !== null && baseEffortMax !== null && baseEffortMin !== baseEffortMax && ( + + (range: {baseEffortMin.toFixed(2)} - {baseEffortMax.toFixed(2)}) + + )} +
+ + {/* Lookup path */} +
+
+ ICT Governance Model: + {governanceModelName || 'Niet ingesteld'} + {usedDefaults.includes('regiemodel') && (default)} +
+
+ Application Type: + {applicationTypeName || 'Niet ingesteld'} + {usedDefaults.includes('applicationType') && (default)} +
+
+ Business Impact Analyse: + {businessImpactAnalyse || 'Niet ingesteld'} + {usedDefaults.includes('businessImpact') && (default)} +
+
+ Hosting: + {applicationManagementHosting || 'Niet ingesteld'} + {usedDefaults.includes('hosting') && (default)} +
+
+ + {/* Factors */} +
Factoren:
+
+ Number of Users: × {numberOfUsersFactor.value.toFixed(2)} + {numberOfUsersFactor.name && ` (${numberOfUsersFactor.name})`} +
+
+ Dynamics Factor: × {dynamicsFactor.value.toFixed(2)} + {dynamicsFactor.name && ` (${dynamicsFactor.name})`} +
+
+ Complexity Factor: × {complexityFactor.value.toFixed(2)} + {complexityFactor.name && ` (${complexityFactor.name})`} +
+ + {/* Hours breakdown */} +
+ Uren per jaar (écht inzetbaar): +
+
+
+ {declarableHoursPerYear.toFixed(1)} uur per jaar +
+
+ ≈ {hoursPerMonth.toFixed(1)} uur per maand +
+
+ ≈ {hoursPerWeekCalculated.toFixed(2)} uur per week +
+
+ ≈ {minutesPerWeek.toFixed(0)} minuten per week +
+
+
Berekening: {HOURS_PER_WEEK_DISPLAY} uur/week × {WORK_WEEKS_PER_YEAR_DISPLAY} weken × {effectiveFte.toFixed(2)} FTE × {DECLARABLE_PERCENTAGE_DISPLAY * 100}% = {declarableHoursPerYear.toFixed(1)} uur/jaar
+
(Netto: {netHoursPerYear.toFixed(1)} uur/jaar, waarvan {DECLARABLE_PERCENTAGE_DISPLAY * 100}% declarabel)
+
+
+
+ )} +
+ ); +} + +export default EffortDisplay; + diff --git a/frontend/src/components/FTECalculator.tsx b/frontend/src/components/FTECalculator.tsx new file mode 100644 index 0000000..41b83f9 --- /dev/null +++ b/frontend/src/components/FTECalculator.tsx @@ -0,0 +1,337 @@ +import { useEffect, useState, useMemo } from 'react'; +import { getReferenceData } from '../services/api'; +import CustomSelect from './CustomSelect'; +import { EffortDisplay } from './EffortDisplay'; +import { useEffortCalculation, getEffectiveFte } from '../hooks/useEffortCalculation'; +import type { ReferenceValue, ApplicationDetails } from '../types'; + +export default function FTECalculator() { + // Reference data state + const [governanceModels, setGovernanceModels] = useState([]); + const [applicationTypes, setApplicationTypes] = useState([]); + const [businessImpactAnalyses, setBusinessImpactAnalyses] = useState([]); + const [applicationManagementHosting, setApplicationManagementHosting] = useState([]); + const [numberOfUsers, setNumberOfUsers] = useState([]); + const [dynamicsFactors, setDynamicsFactors] = useState([]); + const [complexityFactors, setComplexityFactors] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Selected values state + const [selectedGovernanceModel, setSelectedGovernanceModel] = useState(null); + const [selectedApplicationType, setSelectedApplicationType] = useState(null); + const [selectedBusinessImpactAnalyse, setSelectedBusinessImpactAnalyse] = useState(null); + const [selectedHosting, setSelectedHosting] = useState(null); + const [selectedNumberOfUsers, setSelectedNumberOfUsers] = useState(null); + const [selectedDynamicsFactor, setSelectedDynamicsFactor] = useState(null); + const [selectedComplexityFactor, setSelectedComplexityFactor] = useState(null); + + // Load reference data + useEffect(() => { + async function loadData() { + try { + setLoading(true); + setError(null); + const data = await getReferenceData(); + setGovernanceModels(data.governanceModels); + setApplicationTypes(data.applicationTypes); + setBusinessImpactAnalyses(data.businessImpactAnalyses); + setApplicationManagementHosting(data.applicationManagementHosting); + setNumberOfUsers(data.numberOfUsers); + setDynamicsFactors(data.dynamicsFactors); + setComplexityFactors(data.complexityFactors); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load reference data'); + } finally { + setLoading(false); + } + } + loadData(); + }, []); + + // Build a minimal ApplicationDetails object for calculation + const applicationData = useMemo(() => { + // Only create if at least governance model is selected (required for calculation) + if (!selectedGovernanceModel) return null; + + return { + id: 'calculator', + key: 'CALC', + name: 'FTE Calculator', + searchReference: null, + description: null, + supplierProduct: null, + organisation: null, + hostingType: null, + status: null, + businessImportance: null, + businessImpactAnalyse: selectedBusinessImpactAnalyse, + systemOwner: null, + businessOwner: null, + functionalApplicationManagement: null, + technicalApplicationManagement: null, + medischeTechniek: false, + applicationFunctions: [], + dynamicsFactor: selectedDynamicsFactor, + complexityFactor: selectedComplexityFactor, + numberOfUsers: selectedNumberOfUsers, + governanceModel: selectedGovernanceModel, + applicationSubteam: null, + applicationTeam: null, + applicationType: selectedApplicationType, + platform: null, + requiredEffortApplicationManagement: null, + applicationManagementHosting: selectedHosting, + applicationManagementTAM: null, + }; + }, [ + selectedGovernanceModel, + selectedApplicationType, + selectedBusinessImpactAnalyse, + selectedHosting, + selectedNumberOfUsers, + selectedDynamicsFactor, + selectedComplexityFactor, + ]); + + // Use effort calculation hook + const { + calculatedFte, + breakdown: effortBreakdown, + isCalculating, + } = useEffortCalculation({ + application: applicationData, + debounceMs: 300, + }); + + // Reset all fields + const handleReset = () => { + setSelectedGovernanceModel(null); + setSelectedApplicationType(null); + setSelectedBusinessImpactAnalyse(null); + setSelectedHosting(null); + setSelectedNumberOfUsers(null); + setSelectedDynamicsFactor(null); + setSelectedComplexityFactor(null); + }; + + // Sort numberOfUsers by extracting the first number from each option + const sortedNumberOfUsers = useMemo(() => { + const getSortValue = (name: string): number => { + const cleaned = name.replace(/\./g, ''); + const match = cleaned.match(/\d+/); + const num = match ? parseInt(match[0], 10) : 0; + + if (name.startsWith('<')) { + return num - 0.5; + } + if (name.startsWith('>')) { + return num + 0.5; + } + return num; + }; + + return [...numberOfUsers].sort((a, b) => { + const numA = getSortValue(a.name); + const numB = getSortValue(b.name); + return numA - numB; + }); + }, [numberOfUsers]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ {/* Header */} +
+

FTE Calculator

+

+ Bereken de benodigde FTE voor applicatiemanagement op basis van de onderstaande classificatievelden. +

+
+ + {/* Form */} +
+
+

Classificatievelden

+ +
+ +
+ {/* First row: Application Type, Hosting */} +
+ {/* Application Type */} +
+ + { + const selected = applicationTypes.find((t) => t.objectId === value); + setSelectedApplicationType(selected || null); + }} + options={applicationTypes} + placeholder="Selecteer Application Type..." + showSummary={true} + /> +
+ + {/* Hosting */} +
+ + { + const selected = applicationManagementHosting.find((h) => h.objectId === value); + setSelectedHosting(selected || null); + }} + options={applicationManagementHosting} + placeholder="Selecteer Hosting..." + /> +
+
+ + {/* Second row: Business Impact Analyse - Full width */} +
+ + { + const selected = businessImpactAnalyses.find((b) => b.objectId === value); + setSelectedBusinessImpactAnalyse(selected || null); + }} + options={businessImpactAnalyses} + placeholder="Selecteer Business Impact Analyse..." + showSummary={true} + /> +
+ + {/* Third row: Number of Users, Dynamics Factor, Complexity Factor - 3 columns */} +
+ {/* Number of Users */} +
+ + { + const selected = sortedNumberOfUsers.find((u) => u.objectId === value); + setSelectedNumberOfUsers(selected || null); + }} + options={sortedNumberOfUsers} + placeholder="Selecteer Number of Users..." + showSummary={true} + /> +
+ + {/* Dynamics Factor */} +
+ + { + const selected = dynamicsFactors.find((d) => d.objectId === value); + setSelectedDynamicsFactor(selected || null); + }} + options={dynamicsFactors} + placeholder="Selecteer Dynamics Factor..." + showSummary={true} + /> +
+ + {/* Complexity Factor */} +
+ + { + const selected = complexityFactors.find((c) => c.objectId === value); + setSelectedComplexityFactor(selected || null); + }} + options={complexityFactors} + placeholder="Selecteer Complexity Factor..." + showSummary={true} + /> +
+
+
+ + {/* ICT Governance Model - Full width at the end */} +
+ + { + const selected = governanceModels.find((m) => m.objectId === value); + setSelectedGovernanceModel(selected || null); + }} + options={governanceModels} + placeholder="Selecteer ICT Governance Model..." + showSummary={true} + /> +
+
+ + {/* Result */} +
+

Berekening Resultaat

+ + {isCalculating ? ( +
+
+ Berekenen... +
+ ) : ( +
+ +
+ )} + + {!selectedGovernanceModel && ( +

+ Selecteer minimaal het ICT Governance Model om een berekening uit te voeren. +

+ )} +
+
+ ); +} diff --git a/frontend/src/components/GovernanceAnalysis.tsx b/frontend/src/components/GovernanceAnalysis.tsx new file mode 100644 index 0000000..530b5a9 --- /dev/null +++ b/frontend/src/components/GovernanceAnalysis.tsx @@ -0,0 +1,303 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; + +interface ApplicationWithIssues { + id: string; + key: string; + name: string; + status: string | null; + governanceModel: string | null; + businessImpactAnalyse: string | null; + applicationType: string | null; + warnings: string[]; + errors: string[]; +} + +interface GovernanceAnalysisData { + totalApplications: number; + applicationsWithIssues: number; + applications: ApplicationWithIssues[]; +} + +const API_BASE = '/api'; + +// Default statuses to exclude +const DEFAULT_EXCLUDED_STATUSES = ['Closed', 'Deprecated']; + +export default function GovernanceAnalysis() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [excludedStatuses, setExcludedStatuses] = useState(DEFAULT_EXCLUDED_STATUSES); + + useEffect(() => { + async function fetchData() { + setLoading(true); + setError(null); + + try { + const response = await fetch(`${API_BASE}/dashboard/governance-analysis`); + if (!response.ok) { + throw new Error('Failed to fetch governance analysis'); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load data'); + } finally { + setLoading(false); + } + } + + fetchData(); + }, []); + + // Get unique statuses from the data for filtering + const availableStatuses = Array.from( + new Set(data?.applications.map(app => app.status).filter(Boolean) as string[]) + ).sort(); + + // Filter applications + const filteredApplications = data?.applications.filter(app => { + // Filter by excluded statuses + if (app.status && excludedStatuses.includes(app.status)) return false; + + // Filter by search query + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return ( + app.name.toLowerCase().includes(query) || + app.key.toLowerCase().includes(query) || + (app.governanceModel?.toLowerCase().includes(query)) || + (app.businessImpactAnalyse?.toLowerCase().includes(query)) + ); + } + + return true; + }) || []; + + // Toggle status exclusion + const toggleStatusExclusion = (status: string) => { + setExcludedStatuses(prev => + prev.includes(status) + ? prev.filter(s => s !== status) + : [...prev, status] + ); + }; + + // Applications filtered by status (before type/search filtering) + const statusFilteredApplications = data?.applications.filter(app => + !(app.status && excludedStatuses.includes(app.status)) + ) || []; + + // Count statistics based on status-filtered applications + const totalWithIssues = statusFilteredApplications.length; + + if (loading) { + return ( +
+
+
+

Analyseren van regiemodel configuratie...

+

Dit kan even duren...

+
+
+ ); + } + + if (error) { + return ( +
+
+ + + + {error} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Analyse Regiemodel

+

+ Overzicht van applicaties met regiemodel fouten (ongeldig regiemodel voor de BIA classificatie). +
+ Standaard worden Closed en Deprecated applicaties uitgesloten. +

+
+ + + + + Terug naar rapporten + +
+ + {/* Summary Cards */} +
+
+
Totaal geanalyseerd
+
{data?.totalApplications || 0}
+
+
+
Met regiemodel fouten
+
{totalWithIssues}
+ {excludedStatuses.length > 0 && data && totalWithIssues !== data.applicationsWithIssues && ( +
+ ({data.applicationsWithIssues} totaal, {data.applicationsWithIssues - totalWithIssues} uitgesloten) +
+ )} +
+
+ + {/* Filters */} +
+
+ {/* Search */} +
+
+ setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> + + + +
+
+
+ + {/* Status exclusion filter */} + {availableStatuses.length > 0 && ( +
+
+ Status uitsluiten: + {availableStatuses.map((status) => ( + + ))} + {excludedStatuses.length > 0 && ( + + )} +
+
+ )} +
+ + {/* Results */} +
+
+ + {filteredApplications.length} applicatie{filteredApplications.length !== 1 ? 's' : ''} gevonden + +
+ + {filteredApplications.length === 0 ? ( +
+ + + +

Geen applicaties met regiemodel fouten gevonden

+

Alle applicaties hebben een geldig regiemodel voor hun BIA classificatie

+
+ ) : ( +
+ {filteredApplications.map((app) => ( +
+
+
+ + {app.name} + +
+ {app.key} + {app.status && ( + {app.status} + )} + {app.governanceModel && ( + + {app.governanceModel} + + )} + {app.businessImpactAnalyse && ( + + BIA: {app.businessImpactAnalyse} + + )} + {app.applicationType && ( + + {app.applicationType} + + )} +
+
+
+ + {/* Errors */} + {app.errors.length > 0 && ( +
+ {app.errors.map((error, i) => ( +
+ + {error} +
+ ))} +
+ )} + + {/* Warnings */} + {app.warnings.length > 0 && ( +
+ {app.warnings.map((warning, i) => ( +
+ + {warning.startsWith('⚠️') || warning.startsWith('ℹ️') ? '' : 'ℹ️'} + + {warning} +
+ ))} +
+ )} +
+ ))} +
+ )} +
+
+ ); +} + diff --git a/frontend/src/components/ApplicationDetail.tsx b/frontend/src/components/GovernanceModelHelper.tsx similarity index 82% rename from frontend/src/components/ApplicationDetail.tsx rename to frontend/src/components/GovernanceModelHelper.tsx index ffc8090..b3c70bb 100644 --- a/frontend/src/components/ApplicationDetail.tsx +++ b/frontend/src/components/GovernanceModelHelper.tsx @@ -1,34 +1,38 @@ -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, useMemo } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { clsx } from 'clsx'; import { - getApplicationById, + getApplicationForEdit, updateApplication, + forceUpdateApplication, getAISuggestion, getAIPrompt, getAIStatus, getReferenceData, getTaxonomy, getConfig, - calculateEffort, sendChatMessage, clearConversation, AIProvider, AIStatusResponse, + ApiError, + type ConflictError, } from '../services/api'; +import ConflictDialog from './ConflictDialog'; import { useNavigationStore } from '../stores/navigationStore'; import { StatusBadge, BusinessImportanceBadge } from './ApplicationList'; import CustomSelect from './CustomSelect'; +import { EffortDisplay } from './EffortDisplay'; +import { useEffortCalculation, getEffectiveFte } from '../hooks/useEffortCalculation'; import type { ApplicationDetails, AISuggestion, ReferenceValue, ZiraTaxonomy, - EffortCalculationBreakdown, ChatMessage, } from '../types'; -export default function ApplicationDetail() { +export default function GovernanceModelHelper() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { @@ -59,7 +63,9 @@ export default function ApplicationDetail() { const [numberOfUsers, setNumberOfUsers] = useState([]); const [governanceModels, setGovernanceModels] = useState([]); const [applicationFunctions, setApplicationFunctions] = useState([]); - const [applicationClusters, setApplicationClusters] = useState([]); + const [applicationSubteams, setApplicationSubteams] = useState([]); + const [applicationTeams, setApplicationTeams] = useState([]); + const [subteamToTeamMapping, setSubteamToTeamMapping] = useState>({}); const [applicationTypes, setApplicationTypes] = useState([]); const [hostingTypes, setHostingTypes] = useState([]); const [businessImpactAnalyses, setBusinessImpactAnalyses] = useState([]); @@ -73,7 +79,7 @@ export default function ApplicationDetail() { const [selectedComplexity, setSelectedComplexity] = useState(null); const [selectedUsers, setSelectedUsers] = useState(null); const [selectedGovernance, setSelectedGovernance] = useState(null); - const [selectedCluster, setSelectedCluster] = useState(null); + const [selectedSubteam, setSelectedSubteam] = useState(null); const [selectedType, setSelectedType] = useState(null); const [selectedHostingType, setSelectedHostingType] = useState(null); const [selectedBusinessImpactAnalyse, setSelectedBusinessImpactAnalyse] = useState(null); @@ -107,6 +113,11 @@ export default function ApplicationDetail() { // Track changes const [hasChanges, setHasChanges] = useState(false); + // Conflict handling + const [originalUpdatedAt, setOriginalUpdatedAt] = useState(null); + const [conflictError, setConflictError] = useState(null); + const [forceUpdateLoading, setForceUpdateLoading] = useState(false); + // Collapsible state for Application Functions block const [applicationFunctionsExpanded, setApplicationFunctionsExpanded] = useState(false); @@ -116,10 +127,69 @@ export default function ApplicationDetail() { // Jira host URL const [jiraHost, setJiraHost] = useState(''); - // Real-time calculated FTE (updated when fields change, before saving) - const [calculatedEffort, setCalculatedEffort] = useState(null); - const [calculatedBreakdown, setCalculatedBreakdown] = useState(null); - const [calculatingEffort, setCalculatingEffort] = useState(false); + // Build overrides object for effort calculation hook + const effortOverrides = useMemo(() => ({ + governanceModel: selectedGovernance, + applicationType: selectedType, + businessImpactAnalyse: selectedBusinessImpactAnalyse, + applicationManagementHosting: selectedApplicationManagementHosting, + dynamicsFactor: selectedDynamics, + complexityFactor: selectedComplexity, + numberOfUsers: selectedUsers, + hostingType: selectedHostingType, + applicationFunctions: selectedFunctions.length > 0 ? selectedFunctions : undefined, + }), [ + selectedGovernance, + selectedType, + selectedBusinessImpactAnalyse, + selectedApplicationManagementHosting, + selectedDynamics, + selectedComplexity, + selectedUsers, + selectedHostingType, + selectedFunctions, + ]); + + // Use centralized effort calculation hook + const { + calculatedFte: calculatedEffort, + breakdown: calculatedBreakdown, + isCalculating: calculatingEffort, + } = useEffortCalculation({ + application, + overrides: effortOverrides, + debounceMs: 300, + }); + + // Sort numberOfUsers by extracting the first number from each option + // Handles formats like "<100", "100-500", "500-1.000", ">15.000" + const sortedNumberOfUsers = useMemo(() => { + const getSortValue = (name: string): number => { + // Replace . (thousand separator) with nothing + const cleaned = name.replace(/\./g, ''); + // Extract the first number from the string + const match = cleaned.match(/\d+/); + const num = match ? parseInt(match[0], 10) : 0; + + // Items starting with "<" should sort before items with the same number + // So "<100" sorts before "100-500" by subtracting 0.5 + if (name.startsWith('<')) { + return num - 0.5; + } + // Items starting with ">" should sort after items with the same number + // So ">15.000" sorts at the very end + if (name.startsWith('>')) { + return num + 0.5; + } + return num; + }; + + return [...numberOfUsers].sort((a, b) => { + const numA = getSortValue(a.name); + const numB = getSortValue(b.name); + return numA - numB; + }); + }, [numberOfUsers]); useEffect(() => { async function fetchData() { @@ -141,19 +211,23 @@ export default function ApplicationDetail() { setChatExpanded(false); try { const [app, refData, taxData, config] = await Promise.all([ - getApplicationById(id), + getApplicationForEdit(id), // Use edit mode to get fresh data getReferenceData(), getTaxonomy(), getConfig(), ]); setApplication(app); + // Store the original updated timestamp for conflict detection + setOriginalUpdatedAt(app._jiraUpdatedAt || null); setDynamicsFactors(refData.dynamicsFactors); setComplexityFactors(refData.complexityFactors); setNumberOfUsers(refData.numberOfUsers); setGovernanceModels(refData.governanceModels); setApplicationFunctions(refData.applicationFunctions || []); - setApplicationClusters(refData.applicationClusters || []); + setApplicationSubteams(refData.applicationSubteams || []); + setApplicationTeams(refData.applicationTeams || []); + setSubteamToTeamMapping(refData.subteamToTeamMapping || {}); setApplicationTypes(refData.applicationTypes || []); setHostingTypes(refData.hostingTypes || []); setBusinessImpactAnalyses(refData.businessImpactAnalyses || []); @@ -175,17 +249,14 @@ export default function ApplicationDetail() { setSelectedComplexity(app.complexityFactor); setSelectedUsers(app.numberOfUsers); setSelectedGovernance(app.governanceModel); - setSelectedCluster(app.applicationCluster); + setSelectedSubteam(app.applicationSubteam); setSelectedType(app.applicationType); setSelectedHostingType(app.hostingType); setSelectedBusinessImpactAnalyse(app.businessImpactAnalyse); setSelectedApplicationManagementHosting(app.applicationManagementHosting ?? null); setSelectedApplicationManagementTAM(app.applicationManagementTAM ?? null); setOverrideFTE(app.overrideFTE ?? null); - - // Reset calculated effort when loading new application - setCalculatedEffort(null); - setCalculatedBreakdown(null); + // Note: Calculated effort is automatically reset by useEffortCalculation hook when application changes } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load application'); } finally { @@ -195,6 +266,16 @@ export default function ApplicationDetail() { fetchData(); }, [id]); + // Set page title + useEffect(() => { + if (application) { + document.title = `${application.name} - Bewerken | Zuyderland CMDB`; + } + return () => { + document.title = 'Zuyderland CMDB'; + }; + }, [application]); + useEffect(() => { if (!application) return; @@ -215,7 +296,7 @@ export default function ApplicationDetail() { selectedComplexity?.objectId !== application.complexityFactor?.objectId || selectedUsers?.objectId !== application.numberOfUsers?.objectId || selectedGovernance?.objectId !== application.governanceModel?.objectId || - selectedCluster?.objectId !== application.applicationCluster?.objectId || + selectedSubteam?.objectId !== application.applicationSubteam?.objectId || selectedType?.objectId !== application.applicationType?.objectId || selectedHostingType?.objectId !== application.hostingType?.objectId || selectedBusinessImpactAnalyse?.objectId !== application.businessImpactAnalyse?.objectId || @@ -230,76 +311,13 @@ export default function ApplicationDetail() { selectedComplexity, selectedUsers, selectedGovernance, - selectedCluster, + selectedSubteam, selectedType, selectedHostingType, selectedBusinessImpactAnalyse, ]); - // Real-time FTE calculation when relevant fields change - useEffect(() => { - if (!application) { - setCalculatedEffort(null); - setCalculatedBreakdown(null); - return; - } - - // Build application data with current selected values - const currentApplicationData: Partial = { - ...application, - applicationFunctions: selectedFunctions.length > 0 ? selectedFunctions : application.applicationFunctions, - governanceModel: selectedGovernance, - applicationType: selectedType, - numberOfUsers: selectedUsers, - dynamicsFactor: selectedDynamics, - complexityFactor: selectedComplexity, - hostingType: selectedHostingType, - businessImpactAnalyse: selectedBusinessImpactAnalyse, - applicationManagementHosting: selectedApplicationManagementHosting, - }; - - // Debounce the calculation (wait 300ms after last change) - let isMounted = true; - const timeoutId = setTimeout(async () => { - try { - setCalculatingEffort(true); - const result = await calculateEffort(currentApplicationData); - // Only update state if component is still mounted - if (isMounted) { - setCalculatedEffort(result.requiredEffortApplicationManagement); - setCalculatedBreakdown(result.breakdown); - } - } catch (err) { - // Silently fail - don't show error for real-time calculation - // Only log if component is still mounted - if (isMounted) { - console.error('Failed to calculate effort:', err); - setCalculatedEffort(null); - setCalculatedBreakdown(null); - } - } finally { - if (isMounted) { - setCalculatingEffort(false); - } - } - }, 300); - - return () => { - isMounted = false; - clearTimeout(timeoutId); - }; - }, [ - application, - selectedFunctions, - selectedGovernance, - selectedType, - selectedUsers, - selectedDynamics, - selectedComplexity, - selectedHostingType, - selectedBusinessImpactAnalyse, - selectedApplicationManagementHosting, - ]); + // Note: Real-time FTE calculation is now handled by useEffortCalculation hook // Load AI status on mount useEffect(() => { @@ -440,37 +458,8 @@ export default function ApplicationDetail() { }, 100); }; - // Trigger FTE recalculation manually - const triggerFTECalculation = async () => { - if (!application) return; - - // Build application data with current selected values - const currentApplicationData: Partial = { - ...application, - applicationFunctions: selectedFunctions.length > 0 ? selectedFunctions : application.applicationFunctions, - governanceModel: selectedGovernance, - applicationType: selectedType, - numberOfUsers: selectedUsers, - dynamicsFactor: selectedDynamics, - complexityFactor: selectedComplexity, - hostingType: selectedHostingType, - businessImpactAnalyse: selectedBusinessImpactAnalyse, - applicationManagementHosting: selectedApplicationManagementHosting, - }; - - try { - setCalculatingEffort(true); - const result = await calculateEffort(currentApplicationData); - setCalculatedEffort(result.requiredEffortApplicationManagement); - setCalculatedBreakdown(result.breakdown); - } catch (err) { - console.error('Failed to calculate effort:', err); - setCalculatedEffort(null); - setCalculatedBreakdown(null); - } finally { - setCalculatingEffort(false); - } - }; + // Note: FTE recalculation is now handled automatically by useEffortCalculation hook + // which watches the effortOverrides useMemo for changes // Accept AI suggestion for Application Functions with merge or overwrite mode const handleAcceptAIFunctions = async (mergeMode: boolean) => { @@ -535,7 +524,7 @@ export default function ApplicationDetail() { } // Trigger FTE calculation after state update - setTimeout(() => triggerFTECalculation(), 100); + // Note: FTE automatically recalculates via useEffortCalculation hook }; // Accept AI suggestion for a specific field @@ -555,7 +544,7 @@ export default function ApplicationDetail() { ); if (suggested) { setSelectedType(suggested); - setTimeout(() => triggerFTECalculation(), 100); + // Note: FTE automatically recalculates via useEffortCalculation hook } } break; @@ -567,7 +556,7 @@ export default function ApplicationDetail() { ); if (suggested) { setSelectedDynamics(suggested); - setTimeout(() => triggerFTECalculation(), 100); + // Note: FTE automatically recalculates via useEffortCalculation hook } } break; @@ -579,7 +568,7 @@ export default function ApplicationDetail() { ); if (suggested) { setSelectedComplexity(suggested); - setTimeout(() => triggerFTECalculation(), 100); + // Note: FTE automatically recalculates via useEffortCalculation hook } } break; @@ -611,7 +600,7 @@ export default function ApplicationDetail() { console.log('Matched hosting type:', suggested.name); setSelectedApplicationManagementHosting(suggested); requestAnimationFrame(() => { - setTimeout(() => triggerFTECalculation(), 100); + // Note: FTE automatically recalculates via useEffortCalculation hook }); } else { console.warn('Could not find matching hosting type for AI suggestion:', aiValue); @@ -643,7 +632,7 @@ export default function ApplicationDetail() { console.log('Matched TAM:', suggested.name); setSelectedApplicationManagementTAM(suggested); requestAnimationFrame(() => { - setTimeout(() => triggerFTECalculation(), 100); + // Note: FTE automatically recalculates via useEffortCalculation hook }); } else { console.warn('Could not find matching TAM for AI suggestion:', aiValue); @@ -659,7 +648,7 @@ export default function ApplicationDetail() { ); if (suggested) { setSelectedBusinessImpactAnalyse(suggested); - setTimeout(() => triggerFTECalculation(), 100); + // Note: FTE automatically recalculates via useEffortCalculation hook } } break; @@ -671,7 +660,7 @@ export default function ApplicationDetail() { ); if (suggested) { setSelectedGovernance(suggested); - setTimeout(() => triggerFTECalculation(), 100); + // Note: FTE automatically recalculates via useEffortCalculation hook } } break; @@ -710,18 +699,16 @@ export default function ApplicationDetail() { if (aiSuggestion.managementClassification?.governanceModel) { await handleAcceptAIField('governanceModel'); } - - // Final trigger after all state updates - setTimeout(() => triggerFTECalculation(), 200); + // Note: FTE automatically recalculates via useEffortCalculation hook }; const handleNavigateNext = () => { const nextId = getNextId(); if (nextId) { goToNext(); - navigate(`/applications/${nextId}`); + navigate(`/application/${nextId}/edit`); } else { - navigate('/applications'); + navigate('/application/overview'); } }; @@ -729,6 +716,7 @@ export default function ApplicationDetail() { if (!id || !application) return; setSaving(true); + setConflictError(null); try { // Determine source based on AI suggestion acceptance const aiPrimaryCode = aiSuggestion?.primaryFunction.code; @@ -741,13 +729,14 @@ export default function ApplicationDetail() { : 'AI_MODIFIED' : 'MANUAL'; - await updateApplication(id, { - applicationFunctions: selectedFunctions.length > 0 ? selectedFunctions : undefined, + const updates = { + // Always send applicationFunctions, even if empty (to clear the field in Jira) + applicationFunctions: selectedFunctions, dynamicsFactor: selectedDynamics || undefined, complexityFactor: selectedComplexity || undefined, numberOfUsers: selectedUsers || undefined, governanceModel: selectedGovernance || undefined, - applicationCluster: selectedCluster || undefined, + applicationSubteam: selectedSubteam || undefined, applicationType: selectedType || undefined, hostingType: selectedHostingType || undefined, businessImpactAnalyse: selectedBusinessImpactAnalyse || undefined, @@ -755,26 +744,30 @@ export default function ApplicationDetail() { applicationManagementTAM: selectedApplicationManagementTAM?.key || undefined, overrideFTE: overrideFTE !== null ? overrideFTE : (overrideFTE === null && application.overrideFTE !== null ? null : undefined), source, + }; + + // Pass originalUpdatedAt for conflict detection + await updateApplication(id, updates, { + originalUpdatedAt: originalUpdatedAt || undefined, }); if (andNavigate === 'next') { const nextId = getNextId(); if (nextId) { goToNext(); - navigate(`/applications/${nextId}`); + navigate(`/application/${nextId}/edit`); } else { - navigate('/applications'); + navigate('/application/overview'); } } else if (andNavigate === 'close') { - navigate('/applications'); + navigate('/application/overview'); } else { - // Refresh data - const app = await getApplicationById(id); + // Refresh data with fresh timestamp + const app = await getApplicationForEdit(id); setApplication(app); + setOriginalUpdatedAt(app._jiraUpdatedAt || null); setHasChanges(false); - // Reset calculated effort to show saved value - setCalculatedEffort(null); - setCalculatedBreakdown(null); + // Note: Calculated effort is automatically reset by useEffortCalculation hook // Update selected state values to match saved values (this will hide reset buttons) const enrichedSelectedFunctions = (app.applicationFunctions || []).map((appFunc) => { @@ -788,24 +781,111 @@ export default function ApplicationDetail() { setSelectedComplexity(app.complexityFactor); setSelectedUsers(app.numberOfUsers); setSelectedGovernance(app.governanceModel); - setSelectedCluster(app.applicationCluster); + setSelectedSubteam(app.applicationSubteam); setSelectedType(app.applicationType); setSelectedHostingType(app.hostingType); setSelectedBusinessImpactAnalyse(app.businessImpactAnalyse); setOverrideFTE(app.overrideFTE ?? null); } } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save'); + // Check for conflict error + if (err instanceof ApiError && err.isConflict()) { + setConflictError(err.data as ConflictError); + } else { + setError(err instanceof Error ? err.message : 'Failed to save'); + } } finally { setSaving(false); } }; + // Handle force overwrite after conflict + const handleForceOverwrite = async () => { + if (!id || !application) return; + + setForceUpdateLoading(true); + try { + const aiPrimaryCode = aiSuggestion?.primaryFunction.code; + const selectedHasPrimary = aiPrimaryCode && selectedFunctions.some( + (f) => f.key === aiPrimaryCode + ); + const source = aiSuggestion + ? selectedHasPrimary + ? 'AI_ACCEPTED' + : 'AI_MODIFIED' + : 'MANUAL'; + + await forceUpdateApplication(id, { + // Always send applicationFunctions, even if empty (to clear the field in Jira) + applicationFunctions: selectedFunctions, + dynamicsFactor: selectedDynamics || undefined, + complexityFactor: selectedComplexity || undefined, + numberOfUsers: selectedUsers || undefined, + governanceModel: selectedGovernance || undefined, + applicationSubteam: selectedSubteam || undefined, + applicationType: selectedType || undefined, + hostingType: selectedHostingType || undefined, + businessImpactAnalyse: selectedBusinessImpactAnalyse || undefined, + applicationManagementHosting: selectedApplicationManagementHosting?.key || undefined, + applicationManagementTAM: selectedApplicationManagementTAM?.key || undefined, + overrideFTE: overrideFTE !== null ? overrideFTE : (overrideFTE === null && application.overrideFTE !== null ? null : undefined), + source, + }); + + // Close dialog and refresh + setConflictError(null); + const app = await getApplicationForEdit(id); + setApplication(app); + setOriginalUpdatedAt(app._jiraUpdatedAt || null); + setHasChanges(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to force save'); + } finally { + setForceUpdateLoading(false); + } + }; + + // Discard changes and reload + const handleDiscardChanges = async () => { + if (!id) return; + + setConflictError(null); + setLoading(true); + try { + const app = await getApplicationForEdit(id); + setApplication(app); + setOriginalUpdatedAt(app._jiraUpdatedAt || null); + setHasChanges(false); + + // Reset all edit state to loaded values + const enrichedSelectedFunctions = (app.applicationFunctions || []).map((appFunc) => { + const enriched = applicationFunctions.find( + (f) => f.objectId === appFunc.objectId + ); + return enriched || appFunc; + }); + setSelectedFunctions(enrichedSelectedFunctions); + setSelectedDynamics(app.dynamicsFactor); + setSelectedComplexity(app.complexityFactor); + setSelectedUsers(app.numberOfUsers); + setSelectedGovernance(app.governanceModel); + setSelectedSubteam(app.applicationSubteam); + setSelectedType(app.applicationType); + setSelectedHostingType(app.hostingType); + setSelectedBusinessImpactAnalyse(app.businessImpactAnalyse); + setOverrideFTE(app.overrideFTE ?? null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to reload'); + } finally { + setLoading(false); + } + }; + const handlePrevious = () => { const prevId = getPreviousId(); if (prevId) { goToPrevious(); - navigate(`/applications/${prevId}`); + navigate(`/application/${prevId}/edit`); } }; @@ -834,27 +914,60 @@ export default function ApplicationDetail() { return (
+ {/* Conflict Dialog */} + {conflictError && ( + setConflictError(null)} + isLoading={forceUpdateLoading} + /> + )} + {/* Navigation header */}
- - + - - - Terug naar lijst - + + + + Terug + + | + + + + + Overzicht + +
{applicationIds.length > 0 && ( Applicatie {currentIndex + 1} van {applicationIds.length} @@ -1438,7 +1551,7 @@ export default function ApplicationDetail() { ); })} @@ -1745,7 +1866,7 @@ export default function ApplicationDetail() { }} options={applicationManagementHosting} placeholder="Selecteer..." - showSummary={false} + showSummary={true} /> {/* AI Suggestion for Application Management - Hosting */} {aiSuggestion?.managementClassification?.applicationManagementHosting && (() => { @@ -1883,7 +2004,7 @@ export default function ApplicationDetail() { {/* Section 02: Classification */}

Classification

-
+
{/* 1. Business Impact Analyse */}
@@ -1902,25 +2023,18 @@ export default function ApplicationDetail() { )}
- + }} + options={businessImpactAnalyses} + placeholder="Selecteer..." + showSummary={true} + /> {/* AI Suggestion for BIA Classification */} {aiSuggestion?.managementClassification?.biaClassification && (() => { const aiValue = aiSuggestion.managementClassification.biaClassification.value; @@ -1965,6 +2079,8 @@ export default function ApplicationDetail() { })()}
+ {/* 2-4. Number of Users, Dynamics Factor, Complexity Factor - 3 columns */} +
{/* 2. Aantal Gebruikers */} {/* 4. Aantal Gebruikers */}
@@ -1984,23 +2100,18 @@ export default function ApplicationDetail() { )}
- + options={sortedNumberOfUsers} + placeholder="Selecteer..." + showSummary={true} + />
{/* 3. Dynamiek Factor */} @@ -2143,6 +2254,7 @@ export default function ApplicationDetail() { })()}
+
{/* Section 03: Application Management */} @@ -2221,14 +2333,14 @@ export default function ApplicationDetail() { })()}
- {/* 2. Application Cluster - Full width */} + {/* 2. Subteam - Full width */}
- - {selectedCluster?.objectId !== application.applicationCluster?.objectId && ( + + {selectedSubteam?.objectId !== application.applicationSubteam?.objectId && (
@@ -2309,190 +2467,19 @@ export default function ApplicationDetail() {
- {(() => { - // Use calculated effort if available (real-time), otherwise use saved value - const calculatedEffortValue = calculatedEffort !== null ? calculatedEffort : application.requiredEffortApplicationManagement; - // Use override FTE if set, otherwise use calculated - const currentOverrideFTE = overrideFTE !== null ? overrideFTE : (application.overrideFTE ?? null); - const effort = currentOverrideFTE !== null ? currentOverrideFTE : calculatedEffortValue; - const breakdown = calculatedBreakdown || null; - - // Use breakdown values from v25 structure - const baseEffort = breakdown?.baseEffort ?? null; - const baseEffortMin = breakdown?.baseEffortMin ?? null; - const baseEffortMax = breakdown?.baseEffortMax ?? null; - - const numberOfUsersFactor = breakdown?.numberOfUsersFactor.value ?? (calculatedEffort !== null ? selectedUsers?.factor : application.numberOfUsers?.factor) ?? 1.0; - const dynamicsFactorValue = breakdown?.dynamicsFactor.value ?? (calculatedEffort !== null ? selectedDynamics?.factor : application.dynamicsFactor?.factor) ?? 1.0; - const complexityFactorValue = breakdown?.complexityFactor.value ?? (calculatedEffort !== null ? selectedComplexity?.factor : application.complexityFactor?.factor) ?? 1.0; - - const numberOfUsersName = breakdown?.numberOfUsersFactor.name ?? (calculatedEffort !== null ? selectedUsers?.name : application.numberOfUsers?.name) ?? null; - const dynamicsFactorName = breakdown?.dynamicsFactor.name ?? (calculatedEffort !== null ? selectedDynamics?.name : application.dynamicsFactor?.name) ?? null; - const complexityFactorName = breakdown?.complexityFactor.name ?? (calculatedEffort !== null ? selectedComplexity?.name : application.complexityFactor?.name) ?? null; - - const governanceModelName = breakdown?.governanceModelName ?? breakdown?.governanceModel ?? (calculatedEffort !== null ? selectedGovernance?.name : application.governanceModel?.name) ?? null; - const applicationTypeName = breakdown?.applicationType ?? (calculatedEffort !== null ? selectedType?.name : application.applicationType?.name) ?? null; - const businessImpactAnalyse = breakdown?.businessImpactAnalyse ?? null; - const applicationManagementHosting = breakdown?.applicationManagementHosting ?? (calculatedEffort !== null ? selectedApplicationManagementHosting?.name : application.applicationManagementHosting?.name) ?? null; - - // Warnings and errors from v25 breakdown - const warnings = breakdown?.warnings ?? []; - const errors = breakdown?.errors ?? []; - const usedDefaults = breakdown?.usedDefaults ?? []; - const requiresManualAssessment = breakdown?.requiresManualAssessment ?? false; - const isFixedFte = breakdown?.isFixedFte ?? false; - - // Hours from breakdown or calculate - const hoursPerYear = breakdown?.hoursPerYear ?? (effort !== null ? 36 * 46 * effort * 0.75 : 0); - const hoursPerMonth = breakdown?.hoursPerMonth ?? hoursPerYear / 12; - const hoursPerWeekCalculated = breakdown?.hoursPerWeek ?? hoursPerYear / 46; - const minutesPerWeek = hoursPerWeekCalculated * 60; - - // Calculation constants - const hoursPerWeek = 36; - const workWeeksPerYear = 46; - const declarablePercentage = 0.75; - - if (effort !== null && baseEffort !== null) { - // Net hours per year - const netHoursPerYear = hoursPerWeek * workWeeksPerYear * effort; - // Declarable/really usable hours per year - const declarableHoursPerYear = netHoursPerYear * declarablePercentage; - - return ( -
- {/* Errors */} - {errors.length > 0 && ( -
- {errors.map((error, i) => ( -
- - {error} -
- ))} -
- )} - - {/* Warnings */} - {warnings.length > 0 && ( -
- {warnings.map((warning, i) => ( -
- {warning.startsWith('⚠️') || warning.startsWith('ℹ️') ? '' : 'ℹ️'} - {warning} -
- ))} -
- )} - -
- {effort.toFixed(2)} FTE - {currentOverrideFTE !== null && ( - (Override) - )} - {calculatedEffort !== null && calculatedEffort !== application.requiredEffortApplicationManagement && currentOverrideFTE === null && ( - (voorvertoning) - )} - {isFixedFte && ( - (vast) - )} - {requiresManualAssessment && ( - (handmatige beoordeling) - )} -
- {currentOverrideFTE !== null && calculatedEffortValue !== null && ( -
- Berekende waarde: {calculatedEffortValue.toFixed(2)} FTE -
- )} -
-
- Basis FTE: {baseEffort.toFixed(2)} FTE - {baseEffortMin !== null && baseEffortMax !== null && baseEffortMin !== baseEffortMax && ( - - (range: {baseEffortMin.toFixed(2)} - {baseEffortMax.toFixed(2)}) - - )} -
-
-
- ICT Governance Model: - {governanceModelName || 'Niet ingesteld'} - {usedDefaults.includes('regiemodel') && (default)} -
-
- Application Management - Application Type: - {applicationTypeName || 'Niet ingesteld'} - {usedDefaults.includes('applicationType') && (default)} -
-
- Business Impact Analyse: - {businessImpactAnalyse || 'Niet ingesteld'} - {usedDefaults.includes('businessImpact') && (default)} -
-
- Application Management - Hosting: - {applicationManagementHosting || 'Niet ingesteld'} - {usedDefaults.includes('hosting') && (default)} -
-
-
Factoren:
-
- Number of Users: × {numberOfUsersFactor.toFixed(2)} - {numberOfUsersName && ` (${numberOfUsersName})`} -
-
- Dynamics Factor: × {dynamicsFactorValue.toFixed(2)} - {dynamicsFactorName && ` (${dynamicsFactorName})`} -
-
- Complexity Factor: × {complexityFactorValue.toFixed(2)} - {complexityFactorName && ` (${complexityFactorName})`} -
- - {/* Hours breakdown */} -
- Uren per jaar (écht inzetbaar): -
-
-
- {hoursPerYear.toFixed(1)} uur per jaar -
-
- ≈ {hoursPerMonth.toFixed(1)} uur per maand -
-
- ≈ {hoursPerWeekCalculated.toFixed(2)} uur per week -
-
- ≈ {minutesPerWeek.toFixed(0)} minuten per week -
-
-
Berekening: {hoursPerWeek} uur/week × {workWeeksPerYear} weken × {effort.toFixed(2)} FTE × {declarablePercentage * 100}% = {declarableHoursPerYear.toFixed(1)} uur/jaar
-
(Netto: {netHoursPerYear.toFixed(1)} uur/jaar, waarvan {declarablePercentage * 100}% declarabel)
-
-
-
-
- ); - } else if (errors.length > 0) { - return ( -
-
- {errors.map((error, i) => ( -
- - {error} -
- ))} -
- Niet berekend - configuratie onvolledig -
- ); - } else { - return Niet berekend; - } - })()} +

Automatisch berekend op basis van Regiemodel, Application Type, Business Impact Analyse en Hosting (v25) @@ -2512,7 +2499,7 @@ export default function ApplicationDetail() { Vorige

)} - {config?.oauthEnabled ? ( + {config?.authMethod === 'oauth' ? ( <>

Niet geconfigureerd

- Neem contact op met de beheerder om OAuth of een service account te configureren. + Neem contact op met de beheerder om OAuth of een Personal Access Token te configureren.

)} diff --git a/frontend/src/components/ReportsDashboard.tsx b/frontend/src/components/ReportsDashboard.tsx new file mode 100644 index 0000000..885dcd4 --- /dev/null +++ b/frontend/src/components/ReportsDashboard.tsx @@ -0,0 +1,165 @@ +import { Link } from 'react-router-dom'; + +export default function ReportsDashboard() { + const reports = [ + { + id: 'team-dashboard', + title: 'Team-indeling', + description: 'Overzicht van teams, subteams en FTE verdeling.', + icon: ( + + + + ), + href: '/reports/team-dashboard', + color: 'blue', + available: true, + }, + { + id: 'governance-analysis', + title: 'Analyse Regiemodel', + description: 'Overzicht van applicaties met regiemodel en BIA configuratie problemen.', + icon: ( + + + + ), + href: '/reports/governance-analysis', + color: 'orange', + available: true, + }, + { + id: 'classification-progress', + title: 'Classificatie Voortgang', + description: 'Voortgang van ZiRA classificatie per domein en afdeling.', + icon: ( + + + + ), + href: '/reports/classification-progress', + color: 'green', + available: false, + }, + { + id: 'governance-overview', + title: 'Regiemodel Overzicht', + description: 'Verdeling van applicaties per regiemodel en BIA classificatie.', + icon: ( + + + + + ), + href: '/reports/governance-overview', + color: 'purple', + available: false, + }, + { + id: 'data-model', + title: 'Datamodel', + description: 'Overzicht van alle object types, attributen en relaties in het Jira Assets schema.', + icon: ( + + + + ), + href: '/reports/data-model', + color: 'cyan', + available: true, + }, + ]; + + const colorClasses = { + blue: { + bg: 'bg-blue-50', + iconBg: 'bg-blue-100', + iconText: 'text-blue-600', + hover: 'hover:bg-blue-100', + }, + green: { + bg: 'bg-green-50', + iconBg: 'bg-green-100', + iconText: 'text-green-600', + hover: 'hover:bg-green-100', + }, + purple: { + bg: 'bg-purple-50', + iconBg: 'bg-purple-100', + iconText: 'text-purple-600', + hover: 'hover:bg-purple-100', + }, + orange: { + bg: 'bg-orange-50', + iconBg: 'bg-orange-100', + iconText: 'text-orange-600', + hover: 'hover:bg-orange-100', + }, + cyan: { + bg: 'bg-cyan-50', + iconBg: 'bg-cyan-100', + iconText: 'text-cyan-600', + hover: 'hover:bg-cyan-100', + }, + }; + + return ( +
+ {/* Header */} +
+

Rapporten

+

+ Overzicht van beschikbare rapporten en analyses voor de CMDB. +

+
+ + {/* Reports Grid */} +
+ {reports.map((report) => { + const colors = colorClasses[report.color as keyof typeof colorClasses]; + + if (!report.available) { + return ( +
+
+ + Binnenkort + +
+
+ {report.icon} +
+

{report.title}

+

{report.description}

+
+ ); + } + + return ( + +
+ {report.icon} +
+

{report.title}

+

{report.description}

+
+ Bekijk rapport + + + +
+ + ); + })} +
+
+ ); +} + diff --git a/frontend/src/components/SearchDashboard.tsx b/frontend/src/components/SearchDashboard.tsx new file mode 100644 index 0000000..ce25ef3 --- /dev/null +++ b/frontend/src/components/SearchDashboard.tsx @@ -0,0 +1,548 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { searchCMDB, getConfig, CMDBSearchResponse, CMDBSearchResult, CMDBSearchObjectType } from '../services/api'; + +const ITEMS_PER_PAGE = 25; +const APPLICATION_COMPONENT_TYPE_NAME = 'ApplicationComponent'; + +// Helper to strip HTML tags from description +function stripHtml(html: string): string { + return html + .replace(/<[^>]*>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); +} + +// Helper to get attribute value from result +function getAttributeValue(result: CMDBSearchResult, attributeName: string): string | null { + const attr = result.attributes.find(a => a.name === attributeName); + if (attr && attr.values && attr.values.length > 0) { + return attr.values[0]; + } + return null; +} + +// Helper to get status display info +function getStatusInfo(status: string | null): { color: string; bg: string } { + if (!status) return { color: 'text-gray-600', bg: 'bg-gray-100' }; + + const statusLower = status.toLowerCase(); + if (statusLower.includes('production')) return { color: 'text-green-700', bg: 'bg-green-100' }; + if (statusLower.includes('implementation')) return { color: 'text-blue-700', bg: 'bg-blue-100' }; + if (statusLower.includes('deprecated') || statusLower.includes('end of')) return { color: 'text-orange-700', bg: 'bg-orange-100' }; + if (statusLower.includes('closed')) return { color: 'text-red-700', bg: 'bg-red-100' }; + if (statusLower.includes('concept') || statusLower.includes('poc')) return { color: 'text-purple-700', bg: 'bg-purple-100' }; + if (statusLower.includes('shadow')) return { color: 'text-yellow-700', bg: 'bg-yellow-100' }; + + return { color: 'text-gray-600', bg: 'bg-gray-100' }; +} + +export default function SearchDashboard() { + const navigate = useNavigate(); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedTab, setSelectedTab] = useState(null); + const [statusFilter, setStatusFilter] = useState(''); + const [currentPage, setCurrentPage] = useState>(new Map()); + const [jiraHost, setJiraHost] = useState(''); + const [hasSearched, setHasSearched] = useState(false); + + // Fetch Jira host for avatar URLs + useEffect(() => { + getConfig().then(config => { + setJiraHost(config.jiraHost); + }).catch(() => { + // Silently fail, avatars just won't show + }); + }, []); + + // Group results by object type + const resultsByType = useMemo(() => { + if (!searchResults?.results) return new Map(); + + const grouped = new Map(); + for (const result of searchResults.results) { + const typeId = result.objectTypeId; + if (!grouped.has(typeId)) { + grouped.set(typeId, []); + } + grouped.get(typeId)!.push(result); + } + return grouped; + }, [searchResults]); + + // Get object type info by ID + const objectTypeMap = useMemo(() => { + if (!searchResults?.objectTypes) return new Map(); + + const map = new Map(); + for (const ot of searchResults.objectTypes) { + map.set(ot.id, ot); + } + return map; + }, [searchResults]); + + // Get sorted object types (by result count, descending) + const sortedObjectTypes = useMemo(() => { + if (!searchResults?.objectTypes) return []; + + return [...searchResults.objectTypes].sort((a, b) => { + const countA = resultsByType.get(a.id)?.length || 0; + const countB = resultsByType.get(b.id)?.length || 0; + return countB - countA; + }); + }, [searchResults, resultsByType]); + + // Current tab's results + const currentTabResults = useMemo(() => { + if (selectedTab === null) return []; + return resultsByType.get(selectedTab) || []; + }, [selectedTab, resultsByType]); + + // Get unique status values for current tab + const statusOptions = useMemo(() => { + const statuses = new Set(); + for (const result of currentTabResults) { + const status = getAttributeValue(result, 'Status'); + if (status) { + // Handle status objects with nested structure (null check required because typeof null === 'object') + const statusName = status && typeof status === 'object' && (status as any).name + ? (status as any).name + : status; + statuses.add(statusName); + } + } + return Array.from(statuses).sort(); + }, [currentTabResults]); + + // Filter results by status + const filteredResults = useMemo(() => { + if (!statusFilter) return currentTabResults; + + return currentTabResults.filter(result => { + const status = getAttributeValue(result, 'Status'); + if (!status) return false; + // Handle status objects with nested structure (null check required because typeof null === 'object') + const statusName = status && typeof status === 'object' && (status as any).name + ? (status as any).name + : status; + return statusName === statusFilter; + }); + }, [currentTabResults, statusFilter]); + + // Pagination + const pageForCurrentTab = currentPage.get(selectedTab || 0) || 1; + const totalPages = Math.ceil(filteredResults.length / ITEMS_PER_PAGE); + const paginatedResults = filteredResults.slice( + (pageForCurrentTab - 1) * ITEMS_PER_PAGE, + pageForCurrentTab * ITEMS_PER_PAGE + ); + + // Reset pagination when filter changes + useEffect(() => { + if (selectedTab !== null) { + setCurrentPage(prev => new Map(prev).set(selectedTab, 1)); + } + }, [statusFilter, selectedTab]); + + // Auto-select first tab when results arrive + useEffect(() => { + if (sortedObjectTypes.length > 0 && selectedTab === null) { + setSelectedTab(sortedObjectTypes[0].id); + } + }, [sortedObjectTypes, selectedTab]); + + // Perform search + const handleSearch = useCallback((e?: React.FormEvent) => { + e?.preventDefault(); + + if (!searchQuery.trim()) return; + + setLoading(true); + setError(null); + setHasSearched(true); + setSelectedTab(null); + setStatusFilter(''); + setCurrentPage(new Map()); + + searchCMDB(searchQuery.trim()) + .then((results) => { + setSearchResults(results); + + // Auto-select first tab if results exist + if (results.objectTypes && results.objectTypes.length > 0) { + // Sort by count and select the first one + const sorted = [...results.objectTypes].sort((a, b) => { + const countA = results.results.filter(r => r.objectTypeId === a.id).length; + const countB = results.results.filter(r => r.objectTypeId === b.id).length; + return countB - countA; + }); + setSelectedTab(sorted[0].id); + } + }) + .catch((err) => { + setError(err instanceof Error ? err.message : 'Zoeken mislukt'); + setSearchResults(null); + }) + .finally(() => { + setLoading(false); + }); + }, [searchQuery]); + + // Handle tab change + const handleTabChange = (typeId: number) => { + setSelectedTab(typeId); + setStatusFilter(''); + }; + + // Handle page change + const handlePageChange = (newPage: number) => { + if (selectedTab !== null) { + setCurrentPage(prev => new Map(prev).set(selectedTab, newPage)); + } + }; + + // Helper to check if a result is an Application Component (by looking up type name) + const isApplicationComponent = useCallback((result: CMDBSearchResult) => { + const objectType = objectTypeMap.get(result.objectTypeId); + return objectType?.name === APPLICATION_COMPONENT_TYPE_NAME; + }, [objectTypeMap]); + + // Handle result click (for Application Components) + const handleResultClick = (result: CMDBSearchResult) => { + if (isApplicationComponent(result)) { + navigate(`/application/${result.id}`); + } + }; + + // Get avatar URL with Jira host prefix + const getAvatarUrl = (avatarUrl: string) => { + if (!avatarUrl) return null; + if (avatarUrl.startsWith('http')) return avatarUrl; + return `${jiraHost}${avatarUrl}`; + }; + + return ( +
+ {/* Header */} +
+
+ + + +
+

CMDB Zoeken

+

+ Zoek naar applicaties, servers, infrastructuur en andere items in de CMDB. +

+
+ + {/* Search Form */} +
+
+
+ + + +
+ setSearchQuery(e.target.value)} + placeholder="Zoek op naam, key, of beschrijving..." + className="w-full pl-12 pr-28 py-3.5 text-base border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all outline-none" + disabled={loading} + /> + +
+
+ + {/* Error Message */} + {error && ( +
+
+ + + + {error} +
+
+ )} + + {/* Results */} + {hasSearched && searchResults && !loading && ( +
+ {/* Results Summary */} +
+

+ {searchResults.metadata.total} resultaten gevonden + {searchResults.metadata.total !== searchResults.results.length && ( + (eerste {searchResults.results.length} getoond) + )} +

+
+ + {searchResults.results.length === 0 ? ( +
+ + + +

Geen resultaten gevonden voor "{searchQuery}"

+

Probeer een andere zoekterm

+
+ ) : ( + <> + {/* Object Type Tabs */} +
+ +
+ + {/* Status Filter */} + {statusOptions.length > 0 && ( +
+ + + {statusFilter && ( + + )} +
+ )} + + {/* Results List */} +
+ {paginatedResults.map((result) => { + const status = getAttributeValue(result, 'Status'); + // Handle status objects with nested structure (null check required because typeof null === 'object') + const statusDisplay = status && typeof status === 'object' && (status as any).name + ? (status as any).name + : status; + const statusInfo = getStatusInfo(statusDisplay); + const description = getAttributeValue(result, 'Description'); + const isClickable = isApplicationComponent(result); + + return ( +
isClickable && handleResultClick(result)} + className={` + bg-white border border-gray-200 rounded-lg p-4 + ${isClickable + ? 'cursor-pointer hover:border-blue-300 hover:shadow-sm transition-all' + : ''} + `} + > +
+ {/* Avatar */} +
+ {result.avatarUrl && jiraHost ? ( + { + (e.target as HTMLImageElement).style.display = 'none'; + (e.target as HTMLImageElement).parentElement!.innerHTML = ` + + + + `; + }} + /> + ) : ( + + + + )} +
+ + {/* Content */} +
+
+ {result.key} + {statusDisplay && ( + + {statusDisplay} + + )} + {isClickable && ( + + + + + Klik om te openen + + )} +
+

{result.label}

+ {description && ( +

+ {stripHtml(description).substring(0, 200)} + {stripHtml(description).length > 200 && '...'} +

+ )} +
+
+
+ ); + })} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Pagina {pageForCurrentTab} van {totalPages} ({filteredResults.length} items) +

+
+ + +
+
+ )} + + )} +
+ )} + + {/* Quick Links (only show when no search has been performed) */} + {!hasSearched && ( + + )} +
+ ); +} diff --git a/frontend/src/components/TeamDashboard.tsx b/frontend/src/components/TeamDashboard.tsx index c811830..05fb363 100644 --- a/frontend/src/components/TeamDashboard.tsx +++ b/frontend/src/components/TeamDashboard.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; import { Link } from 'react-router-dom'; import { getTeamDashboardData, getReferenceData } from '../services/api'; -import type { TeamDashboardData, TeamDashboardCluster, ApplicationStatus, ReferenceValue } from '../types'; +import type { TeamDashboardData, TeamDashboardTeam, TeamDashboardSubteam, ApplicationStatus, ReferenceValue } from '../types'; const ALL_STATUSES: ApplicationStatus[] = [ 'In Production', @@ -59,7 +59,8 @@ export default function TeamDashboard() { const [initialLoading, setInitialLoading] = useState(true); // Only for first load const [dataLoading, setDataLoading] = useState(false); // For filter changes const [error, setError] = useState(null); - const [expandedClusters, setExpandedClusters] = useState>(new Set()); // Start with all clusters collapsed + const [expandedTeams, setExpandedTeams] = useState>(new Set()); // Track expanded teams + const [expandedSubteams, setExpandedSubteams] = useState>(new Set()); // Track expanded subteams const [expandedPlatforms, setExpandedPlatforms] = useState>(new Set()); // Track expanded platforms // Status filter: excludedStatuses contains statuses that are NOT shown const [excludedStatuses, setExcludedStatuses] = useState(['Closed', 'Deprecated']); // Default: exclude Closed and Deprecated @@ -67,6 +68,31 @@ export default function TeamDashboard() { const [statusDropdownOpen, setStatusDropdownOpen] = useState(false); const [governanceModels, setGovernanceModels] = useState([]); const [hoveredGovModel, setHoveredGovModel] = useState(null); + const hoverTimeoutRef = useRef(null); + + // Hover handlers with delayed hide to prevent flickering when moving between badges + const handleGovModelMouseEnter = useCallback((hoverKey: string) => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + setHoveredGovModel(hoverKey); + }, []); + + const handleGovModelMouseLeave = useCallback(() => { + hoverTimeoutRef.current = setTimeout(() => { + setHoveredGovModel(null); + }, 100); // Small delay to allow moving to another badge + }, []); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + }; + }, []); // Fetch governance models on mount useEffect(() => { @@ -81,6 +107,60 @@ export default function TeamDashboard() { fetchGovernanceModels(); }, []); + // Compute overall KPIs from data + const overallKPIs = useMemo(() => { + if (!data) return null; + + // Sum up total FTE (including min/max bandwidth) + let totalFTE = 0; + let totalMinFTE = 0; + let totalMaxFTE = 0; + let totalApplicationCount = 0; + const overallByGovernanceModel: Record = {}; + + // Aggregate from all teams + data.teams.forEach(team => { + totalFTE += team.totalEffort; + totalMinFTE += team.minEffort ?? 0; + totalMaxFTE += team.maxEffort ?? 0; + totalApplicationCount += team.applicationCount; + + // Aggregate governance model distribution + Object.entries(team.byGovernanceModel).forEach(([model, count]) => { + overallByGovernanceModel[model] = (overallByGovernanceModel[model] || 0) + count; + }); + }); + + // Add unassigned + totalFTE += data.unassigned.totalEffort; + totalMinFTE += data.unassigned.minEffort ?? 0; + totalMaxFTE += data.unassigned.maxEffort ?? 0; + totalApplicationCount += data.unassigned.applicationCount; + + Object.entries(data.unassigned.byGovernanceModel).forEach(([model, count]) => { + overallByGovernanceModel[model] = (overallByGovernanceModel[model] || 0) + count; + }); + + return { + totalFTE, + totalMinFTE, + totalMaxFTE, + totalApplicationCount, + byGovernanceModel: overallByGovernanceModel, + }; + }, [data]); + + // Sort teams always alphabetically (sortOption only affects items within teams/subteams) + const sortedTeams = useMemo(() => { + if (!data) return []; + + return [...data.teams].sort((a, b) => { + const nameA = a.team?.name || ''; + const nameB = b.team?.name || ''; + return nameA.localeCompare(nameB, 'nl', { sensitivity: 'base' }); + }); + }, [data]); + useEffect(() => { async function fetchData() { try { @@ -93,7 +173,7 @@ export default function TeamDashboard() { } const dashboardData = await getTeamDashboardData(excludedStatuses); setData(dashboardData); - // Keep clusters collapsed by default (expandedClusters remains empty) + // Keep subteams collapsed by default (expandedSubteams remains empty) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load team dashboard'); } finally { @@ -122,21 +202,41 @@ export default function TeamDashboard() { }; }, [statusDropdownOpen]); - const toggleCluster = (clusterId: string, event?: React.MouseEvent) => { - // Prevent scroll jump by storing and restoring scroll position + const toggleTeam = (teamId: string, _event?: React.MouseEvent) => { const scrollY = window.scrollY; - setExpandedClusters(prev => { + setExpandedTeams(prev => { const newSet = new Set(prev); - if (newSet.has(clusterId)) { - newSet.delete(clusterId); + if (newSet.has(teamId)) { + newSet.delete(teamId); } else { - newSet.add(clusterId); + newSet.add(teamId); + } + return newSet; + }); + + requestAnimationFrame(() => { + window.scrollTo(0, scrollY); + }); + }; + + const toggleSubteam = (subteamId: string, e?: React.MouseEvent) => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + const scrollY = window.scrollY; + + setExpandedSubteams(prev => { + const newSet = new Set(prev); + if (newSet.has(subteamId)) { + newSet.delete(subteamId); + } else { + newSet.add(subteamId); } return newSet; }); - // Use requestAnimationFrame to restore scroll position after state update requestAnimationFrame(() => { window.scrollTo(0, scrollY); }); @@ -180,560 +280,425 @@ export default function TeamDashboard() { } const hasNoApplications = data ? ( - data.clusters.length === 0 && + data.teams.length === 0 && data.unassigned.applications.length === 0 && data.unassigned.platforms.length === 0 ) : true; - const ClusterBlock = ({ clusterData, isUnassigned = false }: { clusterData: TeamDashboardCluster; isUnassigned?: boolean }) => { - const clusterId = clusterData.cluster?.objectId || 'unassigned'; - const isExpanded = expandedClusters.has(clusterId); - const clusterName = isUnassigned ? 'Nog niet toegekend' : (clusterData.cluster?.name || 'Onbekend'); - - // Helper function to get effective FTE for an application - const getEffectiveFTE = (app: { overrideFTE?: number | null; requiredEffortApplicationManagement?: number | null }) => - app.overrideFTE !== null && app.overrideFTE !== undefined ? app.overrideFTE : (app.requiredEffortApplicationManagement || 0); - - // Use pre-calculated min/max from backend (sum of all min/max FTE values) - const minFTE = clusterData.minEffort ?? 0; - const maxFTE = clusterData.maxEffort ?? 0; - - // Calculate application type distribution - const byApplicationType: Record = {}; - clusterData.applications.forEach(app => { - const appType = app.applicationType?.name || 'Niet ingesteld'; - byApplicationType[appType] = (byApplicationType[appType] || 0) + 1; - }); - clusterData.platforms.forEach(platformWithWorkloads => { - const platformType = platformWithWorkloads.platform.applicationType?.name || 'Niet ingesteld'; - byApplicationType[platformType] = (byApplicationType[platformType] || 0) + 1; - platformWithWorkloads.workloads.forEach(workload => { - const workloadType = workload.applicationType?.name || 'Niet ingesteld'; - byApplicationType[workloadType] = (byApplicationType[workloadType] || 0) + 1; - }); - }); - - // Sort applications based on selected sort option - const sortedApplications = [...clusterData.applications].sort((a, b) => { - if (sortOption === 'alphabetical') { + // Helper function to get effective FTE for an application + const getEffectiveFTE = (app: { overrideFTE?: number | null; requiredEffortApplicationManagement?: number | null }) => + app.overrideFTE !== null && app.overrideFTE !== undefined ? app.overrideFTE : (app.requiredEffortApplicationManagement || 0); + + // Color scheme for team types + const TEAM_TYPE_COLORS: Record = { + 'Business': { bg: 'bg-green-200', text: 'text-green-900' }, + 'Enabling': { bg: 'bg-blue-200', text: 'text-blue-900' }, + 'Staf': { bg: 'bg-purple-200', text: 'text-purple-900' }, + }; + + // Render applications (platforms + regular apps) for a subteam + const renderApplications = (subteamData: TeamDashboardSubteam, sortOpt: SortOption) => { + const sortedApplications = [...subteamData.applications].sort((a, b) => { + if (sortOpt === 'alphabetical') { return a.name.localeCompare(b.name, 'nl', { sensitivity: 'base' }); } else { - // Sort by FTE descending (use override if present, otherwise calculated) - const aFTE = getEffectiveFTE(a); - const bFTE = getEffectiveFTE(b); - return bFTE - aFTE; + return getEffectiveFTE(b) - getEffectiveFTE(a); } }); - // Sort platforms based on selected sort option - const sortedPlatforms = [...clusterData.platforms].sort((a, b) => { - if (sortOption === 'alphabetical') { + const sortedPlatforms = [...subteamData.platforms].sort((a, b) => { + if (sortOpt === 'alphabetical') { return a.platform.name.localeCompare(b.platform.name, 'nl', { sensitivity: 'base' }); } else { - // Sort by total FTE descending return b.totalEffort - a.totalEffort; } }); - return ( -
- + if (sortedApplications.length === 0 && sortedPlatforms.length === 0) { + return

Geen applicaties in dit subteam

; + } - {isExpanded && ( -
- {clusterData.applications.length === 0 && clusterData.platforms.length === 0 ? ( -

Geen applicaties in dit cluster

- ) : ( -
- {/* Platforms with Workloads - shown first */} - {sortedPlatforms.map((platformWithWorkloads) => { - const platformId = platformWithWorkloads.platform.id; - const isPlatformExpanded = expandedPlatforms.has(platformId); - const hasWorkloads = platformWithWorkloads.workloads.length > 0; - const platformGovStyle = getGovernanceModelStyle(platformWithWorkloads.platform.governanceModel?.name); - const platform = platformWithWorkloads.platform; - const platformMinFTE = platform.overrideFTE !== null && platform.overrideFTE !== undefined - ? platform.overrideFTE - : (platform.minFTE ?? platform.requiredEffortApplicationManagement ?? 0); - const platformMaxFTE = platform.overrideFTE !== null && platform.overrideFTE !== undefined - ? platform.overrideFTE - : (platform.maxFTE ?? platform.requiredEffortApplicationManagement ?? 0); - // Calculate total min/max including workloads - const totalMinFTE = platformMinFTE + platformWithWorkloads.workloads.reduce((sum, w) => { - return sum + (w.overrideFTE ?? w.minFTE ?? w.requiredEffortApplicationManagement ?? 0); - }, 0); - const totalMaxFTE = platformMaxFTE + platformWithWorkloads.workloads.reduce((sum, w) => { - return sum + (w.overrideFTE ?? w.maxFTE ?? w.requiredEffortApplicationManagement ?? 0); - }, 0); - - return ( -
- {/* Governance Model indicator */} -
- {platformGovStyle.letter} -
- + return ( +
+ {/* Platforms with Workloads */} + {sortedPlatforms.map((platformWithWorkloads) => { + const platformId = platformWithWorkloads.platform.id; + const isPlatformExpanded = expandedPlatforms.has(platformId); + const hasWorkloads = platformWithWorkloads.workloads.length > 0; + const platformGovStyle = getGovernanceModelStyle(platformWithWorkloads.platform.governanceModel?.name); + const platform = platformWithWorkloads.platform; + + return ( +
+
+ {platformGovStyle.letter} +
+ +
+
+ {hasWorkloads && ( + + )} + +
- {/* Platform header */} -
- {hasWorkloads && ( - - )} - -
-
-
-
{platformWithWorkloads.platform.name}
- - Platform - - {platform.applicationManagementHosting?.name && ( - - {platform.applicationManagementHosting.name} - - )} -
-
{platformWithWorkloads.platform.key}
-
-
- {(() => { - const platformHasOverride = platform.overrideFTE !== null && platform.overrideFTE !== undefined; - const platformCalculated = platform.requiredEffortApplicationManagement || 0; - const workloadsCalculated = platformWithWorkloads.workloads.reduce((sum, w) => - sum + (w.requiredEffortApplicationManagement || 0), 0 - ); - const totalCalculated = platformCalculated + workloadsCalculated; - const hasAnyOverride = platformHasOverride || platformWithWorkloads.workloads.some(w => - w.overrideFTE !== null && w.overrideFTE !== undefined - ); - - return ( - <> -
- {platformWithWorkloads.totalEffort.toFixed(2)} FTE -
-
- {totalMinFTE.toFixed(2)} - {totalMaxFTE.toFixed(2)} -
- {hasAnyOverride && ( -
- (berekend: {totalCalculated.toFixed(2)}) -
- )} -
- Platform: {platformWithWorkloads.platformEffort.toFixed(2)} FTE - {platformHasOverride && platformCalculated !== null && ( - (berekend: {platformCalculated.toFixed(2)}) - )} - {hasWorkloads && ( - <> + Workloads: {platformWithWorkloads.workloadsEffort.toFixed(2)} FTE - )} -
- - ); - })()} -
-
- +
+
{platform.name}
+ Platform
- - {/* Workloads list */} - {hasWorkloads && isPlatformExpanded && ( -
-
-
- Workloads ({platformWithWorkloads.workloads.length}) -
-
-
- {[...platformWithWorkloads.workloads] - .sort((a, b) => { - if (sortOption === 'alphabetical') { - return a.name.localeCompare(b.name, 'nl', { sensitivity: 'base' }); - } else { - // Sort by FTE descending (use override if present, otherwise calculated) - const wlEffectiveFTE = (wl: typeof a) => wl.overrideFTE !== null && wl.overrideFTE !== undefined ? wl.overrideFTE : (wl.requiredEffortApplicationManagement || 0); - const aFTE = wlEffectiveFTE(a); - const bFTE = wlEffectiveFTE(b); - return bFTE - aFTE; - } - }) - .map((workload) => { - const workloadGovStyle = getGovernanceModelStyle(workload.governanceModel?.name); - const workloadType = workload.applicationType?.name || 'Workload'; - const workloadHosting = workload.applicationManagementHosting?.name; - const workloadEffectiveFTE = workload.overrideFTE !== null && workload.overrideFTE !== undefined - ? workload.overrideFTE - : workload.requiredEffortApplicationManagement; - const workloadMinFTE = workload.overrideFTE ?? workload.minFTE ?? workload.requiredEffortApplicationManagement ?? 0; - const workloadMaxFTE = workload.overrideFTE ?? workload.maxFTE ?? workload.requiredEffortApplicationManagement ?? 0; - - return ( -
- {/* Governance Model indicator for workload */} -
- {workloadGovStyle.letter} -
- -
-
-
- {workload.name} - - {workloadType} - - {workloadHosting && ( - - {workloadHosting} - - )} -
-
{workload.key}
-
-
- {workloadEffectiveFTE !== null && workloadEffectiveFTE !== undefined ? ( -
-
- {workloadEffectiveFTE.toFixed(2)} FTE -
-
- {workloadMinFTE.toFixed(2)} - {workloadMaxFTE.toFixed(2)} -
-
- ) : ( -
Niet berekend
- )} -
-
- -
- ); - })} -
+
{platform.key}
+
+
+
+ Platform: {platformWithWorkloads.platformEffort.toFixed(2)} FTE +
+ {platformWithWorkloads.workloads.length > 0 && ( +
+ Workloads: {platformWithWorkloads.workloadsEffort.toFixed(2)} FTE
)}
- ); - })} + +
- {/* Regular applications - shown after platforms */} - {sortedApplications.map((app) => { - const govStyle = getGovernanceModelStyle(app.governanceModel?.name); - const appType = app.applicationType?.name || 'Niet ingesteld'; - const appHosting = app.applicationManagementHosting?.name; - const effectiveFTE = app.overrideFTE !== null && app.overrideFTE !== undefined - ? app.overrideFTE - : app.requiredEffortApplicationManagement; - const appMinFTE = app.overrideFTE !== null && app.overrideFTE !== undefined - ? app.overrideFTE - : (app.minFTE ?? app.requiredEffortApplicationManagement ?? 0); - const appMaxFTE = app.overrideFTE !== null && app.overrideFTE !== undefined - ? app.overrideFTE - : (app.maxFTE ?? app.requiredEffortApplicationManagement ?? 0); - - return ( - - {/* Governance Model indicator */} -
- {govStyle.letter} -
- -
-
-
- {app.name} - - {appType} - - {appHosting && ( - - {appHosting} - - )} -
-
{app.key}
-
-
- {effectiveFTE !== null && effectiveFTE !== undefined ? ( -
-
- {effectiveFTE.toFixed(2)} FTE -
-
- {appMinFTE.toFixed(2)} - {appMaxFTE.toFixed(2)} -
+ {hasWorkloads && isPlatformExpanded && ( +
+
+ {platformWithWorkloads.workloads.map((workload) => { + const workloadGovStyle = getGovernanceModelStyle(workload.governanceModel?.name); + return ( +
+
+ {workloadGovStyle.letter}
- ) : ( -
Niet berekend
+ +
+
{workload.name}
{workload.key}
+
{getEffectiveFTE(workload).toFixed(2)} FTE
+
+ +
+ ); + })} +
+
+ )} +
+
+ ); + })} + + {/* Regular applications */} + {sortedApplications.map((app) => { + const govStyle = getGovernanceModelStyle(app.governanceModel?.name); + const effectiveFTE = getEffectiveFTE(app); + const isConnectedDevice = app.applicationType?.name === 'Connected Device'; + + return ( + +
{govStyle.letter}
+
+
+
+ {app.name} + {isConnectedDevice && ( + Connected Device + )} +
+
{app.key}
+
+
{effectiveFTE.toFixed(2)} FTE
+
+ + ); + })} +
+ ); + }; + + // SubteamBlock component - shows subteam with SUBTEAM label + const SubteamBlock = ({ subteamData, teamId, sortOpt }: { subteamData: TeamDashboardSubteam; teamId: string; sortOpt: SortOption }) => { + const subteamId = `${teamId}-${subteamData.subteam?.objectId || 'no-subteam'}`; + const isExpanded = expandedSubteams.has(subteamId); + const subteamName = subteamData.subteam?.name || 'Geen subteam'; + + return ( +
+
toggleSubteam(subteamId, e)} className="w-full px-4 py-3 hover:bg-gray-50 transition-colors cursor-pointer"> +
+
+ {isExpanded ? ( + + ) : ( + + )} +
+

{subteamName}

+ SUBTEAM + + {/* Governance Model KPI badges - show all models including 0 */} +
+ {[ + ...governanceModels + .map(g => g.name) + .sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })), + 'Niet ingesteld' + ].map((model) => { + const count = subteamData.byGovernanceModel[model] || 0; + const style = getGovernanceModelStyle(model); + const hoverKey = `subteam-${subteamId}-${model}`; + const isHovered = hoveredGovModel === hoverKey; + const govModelData = governanceModels.find(g => g.name === model); + + return ( +
handleGovModelMouseEnter(hoverKey)} + onMouseLeave={handleGovModelMouseLeave} + > +
+ {style.letter} + {count} +
+ + {/* Hover popup - outside of opacity-affected element */} + {isHovered && model !== 'Niet ingesteld' && govModelData && ( +
e.stopPropagation()} + > + {/* Arrow pointer */} +
+ + {/* Header: Summary (Description) */} +
+ {govModelData.summary || model} + {govModelData.description && ( + ({govModelData.description}) )}
+ + {/* Remarks */} + {govModelData.remarks && ( +
+ {govModelData.remarks} +
+ )} + + {/* Application section */} + {govModelData.application && ( +
+
+ Toepassing +
+
+ {govModelData.application} +
+
+ )} + + {/* Fallback message if no data */} + {!govModelData.summary && !govModelData.remarks && !govModelData.application && ( +
+ Geen aanvullende informatie beschikbaar +
+ )}
- - ); - })} + )} +
+ ); + })} +
+ +
+
+
{subteamData.totalEffort.toFixed(2)} FTE
+
+ {subteamData.applicationCount} applicaties + {(subteamData.minEffort !== undefined && subteamData.maxEffort !== undefined) && ( + + ({subteamData.minEffort.toFixed(2)} - {subteamData.maxEffort.toFixed(2)} FTE) + + )}
+
+
+
+ + {isExpanded && ( +
+ {renderApplications(subteamData, sortOpt)} +
+ )} +
+ ); + }; + + // TeamBlock component - shows team with TEAM label and Type badge + const TeamBlock = ({ teamData, sortOpt }: { teamData: TeamDashboardTeam; sortOpt: SortOption }) => { + const teamId = teamData.team?.objectId || 'unassigned'; + const isExpanded = expandedTeams.has(teamId); + const teamName = teamData.team?.name || 'Onbekend'; + const teamType = teamData.team?.teamType; + const typeColors = teamType && TEAM_TYPE_COLORS[teamType] ? TEAM_TYPE_COLORS[teamType] : { bg: 'bg-gray-100', text: 'text-gray-600' }; + + return ( +
+
toggleTeam(teamId)} className="w-full px-6 py-4 hover:bg-gray-100 transition-colors text-left cursor-pointer"> +
+
+ {isExpanded ? ( + + ) : ( + + )} +
+

{teamName}

+ {teamType ? ( + {teamType} + ) : ( + Type onbekend + )} + + {/* Governance Model KPI badges - show all models including 0 */} +
+ {[ + ...governanceModels + .map(g => g.name) + .sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })), + 'Niet ingesteld' + ].map((model) => { + const count = teamData.byGovernanceModel[model] || 0; + const style = getGovernanceModelStyle(model); + const hoverKey = `team-${teamId}-${model}`; + const isHovered = hoveredGovModel === hoverKey; + const govModelData = governanceModels.find(g => g.name === model); + + return ( +
handleGovModelMouseEnter(hoverKey)} + onMouseLeave={handleGovModelMouseLeave} + > +
+ {style.letter} + {count} +
+ + {/* Hover popup - outside of opacity-affected element */} + {isHovered && model !== 'Niet ingesteld' && govModelData && ( +
e.stopPropagation()} + > + {/* Arrow pointer */} +
+ + {/* Header: Summary (Description) */} +
+ {govModelData.summary || model} + {govModelData.description && ( + ({govModelData.description}) + )} +
+ + {/* Remarks */} + {govModelData.remarks && ( +
+ {govModelData.remarks} +
+ )} + + {/* Application section */} + {govModelData.application && ( +
+
+ Toepassing +
+
+ {govModelData.application} +
+
+ )} + + {/* Fallback message if no data */} + {!govModelData.summary && !govModelData.remarks && !govModelData.application && ( +
+ Geen aanvullende informatie beschikbaar +
+ )} +
+ )} +
+ ); + })} +
+ +
+
+
{teamData.totalEffort.toFixed(2)} FTE
+
+ {teamData.applicationCount} applicaties • {teamData.subteams.length} subteams + {(teamData.minEffort !== undefined && teamData.maxEffort !== undefined) && ( + + ({teamData.minEffort.toFixed(2)} - {teamData.maxEffort.toFixed(2)} FTE) + + )} +
+
+
+
+ + {isExpanded && ( +
+ {teamData.subteams.length === 0 ? ( +

Geen subteams

+ ) : ( + teamData.subteams.map((subteamData, idx) => ( + + )) )}
)} @@ -746,7 +711,7 @@ export default function TeamDashboard() {

Team-indeling

- Overzicht van applicaties gegroepeerd per Application Cluster + Overzicht van applicaties gegroepeerd per Team en Subteam

@@ -849,6 +814,151 @@ export default function TeamDashboard() {
+ {/* KPI Bar */} + {overallKPIs && ( +
+
+ {/* Total FTE - Primary KPI */} +
+
+ + + + Totaal FTE +
+
+ {overallKPIs.totalFTE.toFixed(2)} FTE +
+
+ Bandbreedte: {overallKPIs.totalMinFTE.toFixed(2)} - {overallKPIs.totalMaxFTE.toFixed(2)} FTE +
+
+ + {/* Application Count */} +
+
+ + + + Application Components +
+
+ {overallKPIs.totalApplicationCount} +
+
+ weergegeven +
+
+ + {/* Governance Model Distribution */} +
+
+ + + + Per Regiemodel + +
+
+ {/* Show all governance models from Jira Assets + "Niet ingesteld" */} + {[ + ...governanceModels + .map(g => g.name) + .sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })), + 'Niet ingesteld' + ].map((govModel) => { + const count = overallKPIs.byGovernanceModel[govModel] || 0; + const colors = (() => { + if (govModel.includes('Regiemodel A')) return { bg: '#20556B', text: '#FFFFFF' }; + if (govModel.includes('Regiemodel B+') || govModel.includes('B+')) return { bg: '#286B86', text: '#FFFFFF' }; + if (govModel.includes('Regiemodel B')) return { bg: '#286B86', text: '#FFFFFF' }; + if (govModel.includes('Regiemodel C')) return { bg: '#81CBF2', text: '#20556B' }; + if (govModel.includes('Regiemodel D')) return { bg: '#F5A733', text: '#FFFFFF' }; + if (govModel.includes('Regiemodel E')) return { bg: '#E95053', text: '#FFFFFF' }; + if (govModel === 'Niet ingesteld') return { bg: '#4B5563', text: '#9CA3AF' }; + return { bg: '#6B7280', text: '#FFFFFF' }; + })(); + const shortLabel = govModel === 'Niet ingesteld' + ? '?' + : (govModel.match(/Regiemodel\s+(.+)/i)?.[1] || govModel.charAt(0)); + const govModelData = governanceModels.find(g => g.name === govModel); + const hoverKey = `header-${govModel}`; + const isHovered = hoveredGovModel === hoverKey; + + return ( +
handleGovModelMouseEnter(hoverKey)} + onMouseLeave={handleGovModelMouseLeave} + > +
+ {shortLabel} +
+
+ {count} +
+ + {/* Hover popup */} + {isHovered && govModel !== 'Niet ingesteld' && ( +
+ {/* Arrow pointer */} +
+ + {/* Header: Summary (Description) */} +
+ {govModelData?.summary || govModel} + {govModelData?.description && ( + ({govModelData.description}) + )} +
+ + {/* Remarks */} + {govModelData?.remarks && ( +
+ {govModelData.remarks} +
+ )} + + {/* Application section */} + {govModelData?.application && ( +
+
+ Toepassing +
+
+ {govModelData.application} +
+
+ )} + + {/* Fallback message if no data */} + {!govModelData && ( +
+ Geen aanvullende informatie beschikbaar +
+ )} +
+ )} +
+ ); + })} +
+
+
+
+ )} + {/* Error message */} {error && (
@@ -866,34 +976,127 @@ export default function TeamDashboard() {
)} - {/* Clusters */} - {!dataLoading && data && data.clusters.length > 0 && ( + {/* Teams */} + {!dataLoading && data && sortedTeams.length > 0 && (
- {data.clusters.map((clusterData) => ( - + {sortedTeams.map((teamData) => ( + ))}
)} {/* Unassigned applications */} {!dataLoading && data && (data.unassigned.applications.length > 0 || data.unassigned.platforms.length > 0) && ( -
- -
-

- Deze applicaties zijn nog niet toegekend aan een cluster. -

+
+
+

Nog niet toegekend

+ + {/* Governance Model KPI badges - show all models including 0 */} +
+ {[ + ...governanceModels + .map(g => g.name) + .sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })), + 'Niet ingesteld' + ].map((model) => { + const count = data.unassigned.byGovernanceModel[model] || 0; + const style = getGovernanceModelStyle(model); + const hoverKey = `unassigned-${model}`; + const isHovered = hoveredGovModel === hoverKey; + const govModelData = governanceModels.find(g => g.name === model); + + return ( +
handleGovModelMouseEnter(hoverKey)} + onMouseLeave={handleGovModelMouseLeave} + > +
+ {style.letter} + {count} +
+ + {/* Hover popup - outside of opacity-affected element */} + {isHovered && model !== 'Niet ingesteld' && govModelData && ( +
e.stopPropagation()} + > + {/* Arrow pointer */} +
+ + {/* Header: Summary (Description) */} +
+ {govModelData.summary || model} + {govModelData.description && ( + ({govModelData.description}) + )} +
+ + {/* Remarks */} + {govModelData.remarks && ( +
+ {govModelData.remarks} +
+ )} + + {/* Application section */} + {govModelData.application && ( +
+
+ Toepassing +
+
+ {govModelData.application} +
+
+ )} + + {/* Fallback message if no data */} + {!govModelData.summary && !govModelData.remarks && !govModelData.application && ( +
+ Geen aanvullende informatie beschikbaar +
+ )} +
+ )} +
+ ); + })} +
+ +
+
+
{data.unassigned.totalEffort.toFixed(2)} FTE
+
+ {data.unassigned.applicationCount} applicaties + {(data.unassigned.minEffort !== undefined && data.unassigned.maxEffort !== undefined) && ( + + ({data.unassigned.minEffort.toFixed(2)} - {data.unassigned.maxEffort.toFixed(2)} FTE) + + )} +
+
+ {renderApplications(data.unassigned, sortOption)} +

+ Deze applicaties zijn nog niet toegekend aan een team. +

)} diff --git a/frontend/src/hooks/useEffortCalculation.ts b/frontend/src/hooks/useEffortCalculation.ts new file mode 100644 index 0000000..e3bdf26 --- /dev/null +++ b/frontend/src/hooks/useEffortCalculation.ts @@ -0,0 +1,212 @@ +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { calculateEffort } from '../services/api'; +import type { ApplicationDetails, EffortCalculationBreakdown, ReferenceValue } from '../types'; + +export interface EffortCalculationResult { + /** The calculated FTE value (after all factors applied) */ + calculatedFte: number | null; + /** The full breakdown of the calculation */ + breakdown: EffortCalculationBreakdown | null; + /** Whether a calculation is in progress */ + isCalculating: boolean; + /** Any error that occurred during calculation */ + error: string | null; + /** Manually trigger a recalculation */ + recalculate: () => Promise; +} + +export interface EffortCalculationOverrides { + governanceModel?: ReferenceValue | null; + applicationType?: ReferenceValue | null; + businessImpactAnalyse?: ReferenceValue | null; + applicationManagementHosting?: ReferenceValue | null; + dynamicsFactor?: ReferenceValue | null; + complexityFactor?: ReferenceValue | null; + numberOfUsers?: ReferenceValue | null; + hostingType?: ReferenceValue | null; + applicationFunctions?: ReferenceValue[]; +} + +export interface EffortCalculationInput { + /** Base application data */ + application: ApplicationDetails | null; + /** Optional overrides for real-time preview (e.g., when user changes fields) */ + overrides?: EffortCalculationOverrides; + /** Whether to auto-calculate on input changes (default: true) */ + autoCalculate?: boolean; + /** Debounce delay in milliseconds (default: 150) */ + debounceMs?: number; +} + +/** + * Build application data with overrides applied. + * Exported for use in components that need manual control. + */ +export function buildApplicationDataWithOverrides( + application: ApplicationDetails, + overrides?: EffortCalculationOverrides +): ApplicationDetails { + if (!overrides) return application; + + return { + ...application, + governanceModel: overrides.governanceModel !== undefined + ? overrides.governanceModel + : application.governanceModel, + applicationType: overrides.applicationType !== undefined + ? overrides.applicationType + : application.applicationType, + businessImpactAnalyse: overrides.businessImpactAnalyse !== undefined + ? overrides.businessImpactAnalyse + : application.businessImpactAnalyse, + applicationManagementHosting: overrides.applicationManagementHosting !== undefined + ? overrides.applicationManagementHosting + : application.applicationManagementHosting, + dynamicsFactor: overrides.dynamicsFactor !== undefined + ? overrides.dynamicsFactor + : application.dynamicsFactor, + complexityFactor: overrides.complexityFactor !== undefined + ? overrides.complexityFactor + : application.complexityFactor, + numberOfUsers: overrides.numberOfUsers !== undefined + ? overrides.numberOfUsers + : application.numberOfUsers, + hostingType: overrides.hostingType !== undefined + ? overrides.hostingType + : application.hostingType, + applicationFunctions: overrides.applicationFunctions !== undefined + ? overrides.applicationFunctions + : application.applicationFunctions, + }; +} + +/** + * Custom hook for FTE effort calculation. + * Centralizes the logic for calculating application management effort. + * + * Usage (simple - auto-calculate when application changes): + * ```tsx + * const { calculatedFte, breakdown } = useEffortCalculation({ application }); + * ``` + * + * Usage (with overrides for form preview): + * ```tsx + * const { calculatedFte, breakdown } = useEffortCalculation({ + * application, + * overrides: { governanceModel: selectedGovernanceModel }, + * debounceMs: 300, + * }); + * ``` + */ +export function useEffortCalculation({ + application, + overrides, + autoCalculate = true, + debounceMs = 150, +}: EffortCalculationInput): EffortCalculationResult { + const [calculatedFte, setCalculatedFte] = useState(null); + const [breakdown, setBreakdown] = useState(null); + const [isCalculating, setIsCalculating] = useState(false); + const [error, setError] = useState(null); + + // Track mounted state to prevent state updates after unmount + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + // Build the application data with any overrides applied + const applicationData = useMemo(() => { + if (!application) return null; + return buildApplicationDataWithOverrides(application, overrides); + }, [application, overrides]); + + // Perform the calculation + const performCalculation = useCallback(async () => { + if (!applicationData) { + setCalculatedFte(null); + setBreakdown(null); + return; + } + + setIsCalculating(true); + setError(null); + + try { + const result = await calculateEffort(applicationData); + + if (isMountedRef.current) { + setCalculatedFte(result.requiredEffortApplicationManagement); + setBreakdown(result.breakdown); + } + } catch (err) { + if (isMountedRef.current) { + console.error('Failed to calculate effort:', err); + setError(err instanceof Error ? err.message : 'Calculation failed'); + setCalculatedFte(null); + setBreakdown(null); + } + } finally { + if (isMountedRef.current) { + setIsCalculating(false); + } + } + }, [applicationData]); + + // Auto-calculate when dependencies change + useEffect(() => { + if (!autoCalculate) return; + if (!application) { + setCalculatedFte(null); + setBreakdown(null); + return; + } + + // Debounce to prevent excessive API calls + const timeoutId = setTimeout(() => { + performCalculation(); + }, debounceMs); + + return () => clearTimeout(timeoutId); + }, [autoCalculate, application, applicationData, debounceMs, performCalculation]); + + // Reset when application ID changes + useEffect(() => { + setCalculatedFte(null); + setBreakdown(null); + setError(null); + }, [application?.id]); + + return { + calculatedFte, + breakdown, + isCalculating, + error, + recalculate: performCalculation, + }; +} + +/** + * Get the effective FTE value considering override + */ +export function getEffectiveFte( + calculatedFte: number | null, + overrideFte: number | null | undefined, + fallbackFte: number | null | undefined +): number | null { + // If override is set, use it + if (overrideFte !== null && overrideFte !== undefined) { + return overrideFte; + } + // Otherwise use calculated value + if (calculatedFte !== null) { + return calculatedFte; + } + // Fallback to stored value + return fallbackFte ?? null; +} + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a8c1b35..3d43d93 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -14,6 +14,48 @@ import type { const API_BASE = '/api'; +// ============================================================================= +// Error Types +// ============================================================================= + +export interface ConflictError { + status: 'conflict'; + message: string; + conflicts?: Array<{ + field: string; + fieldName: string; + proposedValue: unknown; + jiraValue: unknown; + }>; + jiraUpdatedAt?: string; + canMerge?: boolean; + warning?: string; + actions: { + forceOverwrite: boolean; + merge: boolean; + discard: boolean; + }; +} + +export class ApiError extends Error { + constructor( + message: string, + public status: number, + public data?: unknown + ) { + super(message); + this.name = 'ApiError'; + } + + isConflict(): this is ApiError & { data: ConflictError } { + return this.status === 409; + } +} + +// ============================================================================= +// Base Fetch +// ============================================================================= + async function fetchApi( endpoint: string, options: RequestInit = {} @@ -27,14 +69,21 @@ async function fetchApi( }); if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || error.message || 'API request failed'); + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new ApiError( + errorData.error || errorData.message || 'API request failed', + response.status, + errorData + ); } return response.json(); } +// ============================================================================= // Applications +// ============================================================================= + export async function searchApplications( filters: SearchFilters, page: number = 1, @@ -50,6 +99,49 @@ export async function getApplicationById(id: string): Promise(`/applications/${id}`); } +/** + * Get application for editing (force refresh from Jira) + * Returns fresh data with _jiraUpdatedAt for conflict detection + */ +export async function getApplicationForEdit(id: string): Promise { + return fetchApi(`/applications/${id}?mode=edit`); +} + +// Related objects response type +export interface RelatedObject { + id: number; + key: string; + name: string; + label: string; + attributes: Record; +} + +export interface RelatedObjectsResponse { + objects: RelatedObject[]; + total: number; +} + +export async function getRelatedObjects( + applicationId: string, + objectType: string, + attributes?: string[] +): Promise { + const params = attributes && attributes.length > 0 + ? `?attributes=${encodeURIComponent(attributes.join(','))}` + : ''; + return fetchApi(`/applications/${applicationId}/related/${objectType}${params}`); +} + +export interface UpdateApplicationOptions { + /** The _jiraUpdatedAt from when the application was loaded for editing */ + originalUpdatedAt?: string; +} + +/** + * Update application with optional conflict detection + * + * @throws {ApiError} with status 409 if there's a conflict + */ export async function updateApplication( id: string, updates: { @@ -58,7 +150,41 @@ export async function updateApplication( complexityFactor?: ReferenceValue; numberOfUsers?: ReferenceValue; governanceModel?: ReferenceValue; - applicationCluster?: ReferenceValue; + applicationSubteam?: ReferenceValue; + applicationTeam?: ReferenceValue; + applicationType?: ReferenceValue; + hostingType?: ReferenceValue; + businessImpactAnalyse?: ReferenceValue; + applicationManagementHosting?: string; + applicationManagementTAM?: string; + overrideFTE?: number | null; + source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL'; + }, + options?: UpdateApplicationOptions +): Promise { + const body = options?.originalUpdatedAt + ? { updates, _jiraUpdatedAt: options.originalUpdatedAt } + : updates; + + return fetchApi(`/applications/${id}`, { + method: 'PUT', + body: JSON.stringify(body), + }); +} + +/** + * Force update application (ignore conflicts) + */ +export async function forceUpdateApplication( + id: string, + updates: { + applicationFunctions?: ReferenceValue[]; + dynamicsFactor?: ReferenceValue; + complexityFactor?: ReferenceValue; + numberOfUsers?: ReferenceValue; + governanceModel?: ReferenceValue; + applicationSubteam?: ReferenceValue; + applicationTeam?: ReferenceValue; applicationType?: ReferenceValue; hostingType?: ReferenceValue; businessImpactAnalyse?: ReferenceValue; @@ -68,7 +194,7 @@ export async function updateApplication( source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL'; } ): Promise { - return fetchApi(`/applications/${id}`, { + return fetchApi(`/applications/${id}/force`, { method: 'PUT', body: JSON.stringify(updates), }); @@ -94,7 +220,55 @@ export async function getApplicationHistory(id: string): Promise(`/applications/${id}/history`); } +// ============================================================================= +// Cache Management +// ============================================================================= + +export interface CacheStatus { + cache: { + totalObjects: number; + objectsByType: Record; + totalRelations: number; + lastFullSync: string | null; + lastIncrementalSync: string | null; + isWarm: boolean; + dbSizeBytes: number; + }; + sync: { + isRunning: boolean; + isSyncing: boolean; + lastFullSync: string | null; + lastIncrementalSync: string | null; + nextIncrementalSync: string | null; + incrementalInterval: number; + }; + supportedTypes: string[]; +} + +export async function getCacheStatus(): Promise { + return fetchApi('/cache/status'); +} + +export async function triggerSync(): Promise<{ status: string; message: string }> { + return fetchApi<{ status: string; message: string }>('/cache/sync', { + method: 'POST', + }); +} + +export async function triggerTypeSync(objectType: string): Promise<{ + status: string; + objectType: string; + stats: { objectsProcessed: number; relationsExtracted: number; duration: number }; +}> { + return fetchApi(`/cache/sync/${objectType}`, { + method: 'POST', + }); +} + +// ============================================================================= // AI Provider type +// ============================================================================= + export type AIProvider = 'claude' | 'openai'; // AI Status response type @@ -112,7 +286,10 @@ export interface AIStatusResponse { }; } +// ============================================================================= // Classifications +// ============================================================================= + export async function getAISuggestion(id: string, provider?: AIProvider): Promise { const url = provider ? `/classifications/suggest/${id}?provider=${provider}` @@ -144,7 +321,10 @@ export async function getAIPrompt(id: string): Promise<{ prompt: string }> { return fetchApi(`/classifications/prompt/${id}`); } +// ============================================================================= // Reference Data +// ============================================================================= + export async function getReferenceData(): Promise<{ dynamicsFactors: ReferenceValue[]; complexityFactors: ReferenceValue[]; @@ -153,12 +333,14 @@ export async function getReferenceData(): Promise<{ organisations: ReferenceValue[]; hostingTypes: ReferenceValue[]; applicationFunctions: ReferenceValue[]; - applicationClusters: ReferenceValue[]; + applicationSubteams: ReferenceValue[]; + applicationTeams: ReferenceValue[]; applicationTypes: ReferenceValue[]; businessImportance: ReferenceValue[]; businessImpactAnalyses: ReferenceValue[]; applicationManagementHosting: ReferenceValue[]; applicationManagementTAM: ReferenceValue[]; + subteamToTeamMapping: Record; }> { return fetchApi('/reference-data'); } @@ -191,8 +373,8 @@ export async function getHostingTypes(): Promise { return fetchApi('/reference-data/hosting-types'); } -export async function getApplicationClusters(): Promise { - return fetchApi('/reference-data/application-clusters'); +export async function getApplicationSubteams(): Promise { + return fetchApi('/reference-data/application-subteams'); } export async function getApplicationTypes(): Promise { @@ -211,12 +393,18 @@ export async function getBusinessImpactAnalyses(): Promise { return fetchApi('/reference-data/business-impact-analyses'); } +// ============================================================================= // Config +// ============================================================================= + export async function getConfig(): Promise<{ jiraHost: string }> { return fetchApi<{ jiraHost: string }>('/config'); } +// ============================================================================= // Dashboard +// ============================================================================= + export async function getDashboardStats(forceRefresh: boolean = false): Promise { const params = forceRefresh ? '?refresh=true' : ''; return fetchApi(`/dashboard/stats${params}`); @@ -226,7 +414,10 @@ export async function getRecentClassifications(limit: number = 10): Promise(`/dashboard/recent?limit=${limit}`); } +// ============================================================================= // Team Dashboard +// ============================================================================= + export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise { const params = new URLSearchParams(); // Always send excludedStatuses parameter, even if empty, so backend knows the user's intent @@ -235,7 +426,10 @@ export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[] return fetchApi(`/applications/team-dashboard?${queryString}`); } +// ============================================================================= // Configuration +// ============================================================================= + export interface EffortCalculationConfig { governanceModelRules: Array<{ governanceModel: string; @@ -365,7 +559,10 @@ export async function updateEffortCalculationConfigV25(config: EffortCalculation }); } +// ============================================================================= // AI Chat +// ============================================================================= + import type { ChatMessage, ChatResponse } from '../types'; export async function sendChatMessage( @@ -389,3 +586,98 @@ export async function clearConversation(conversationId: string): Promise<{ succe method: 'DELETE', }); } + +// ============================================================================= +// CMDB Search +// ============================================================================= + +export interface CMDBSearchObjectType { + id: number; + name: string; + iconUrl: string; +} + +export interface CMDBSearchResultAttribute { + id: number; + name: string; + objectTypeAttributeId: number; + values: string[]; +} + +export interface CMDBSearchResult { + id: number; + key: string; + label: string; + objectTypeId: number; + avatarUrl: string; + attributes: CMDBSearchResultAttribute[]; +} + +export interface CMDBSearchResponse { + metadata: { + count: number; + offset: number; + limit: number; + total: number; + criteria: { query: string; type: string; schema: number }; + }; + objectTypes: CMDBSearchObjectType[]; + results: CMDBSearchResult[]; +} + +// CMDB free-text search +export async function searchCMDB(query: string, limit: number = 10000): Promise { + return fetchApi(`/search?query=${encodeURIComponent(query)}&limit=${limit}`); +} + +// ============================================================================= +// Schema / Data Model +// ============================================================================= + +export interface SchemaAttributeDefinition { + jiraId: number; + name: string; + fieldName: string; + type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown'; + isMultiple: boolean; + isEditable: boolean; + isRequired: boolean; + isSystem: boolean; + referenceTypeId?: number; + referenceTypeName?: string; + description?: string; +} + +export interface SchemaObjectTypeDefinition { + jiraTypeId: number; + name: string; + typeName: string; + syncPriority: number; + objectCount: number; + attributes: SchemaAttributeDefinition[]; + incomingLinks: Array<{ + fromType: string; + fromTypeName: string; + attributeName: string; + isMultiple: boolean; + }>; + outgoingLinks: Array<{ + toType: string; + toTypeName: string; + attributeName: string; + isMultiple: boolean; + }>; +} + +export interface SchemaResponse { + metadata: { + generatedAt: string; + objectTypeCount: number; + totalAttributes: number; + }; + objectTypes: Record; +} + +export async function getSchema(): Promise { + return fetchApi('/schema'); +} diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index b59efb4..218d8af 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -9,6 +9,9 @@ export interface User { } interface AuthConfig { + // The configured authentication method + authMethod: 'pat' | 'oauth' | 'none'; + // Legacy fields (for backward compatibility) oauthEnabled: boolean; serviceAccountEnabled: boolean; jiraHost: string; diff --git a/frontend/src/stores/searchStore.ts b/frontend/src/stores/searchStore.ts index e552c57..99570d7 100644 --- a/frontend/src/stores/searchStore.ts +++ b/frontend/src/stores/searchStore.ts @@ -11,7 +11,7 @@ interface SearchState { setGovernanceModel: (value: 'all' | 'filled' | 'empty') => void; setDynamicsFactor: (value: 'all' | 'filled' | 'empty') => void; setComplexityFactor: (value: 'all' | 'filled' | 'empty') => void; - setApplicationCluster: (value: 'all' | 'filled' | 'empty') => void; + setApplicationSubteam: (value: 'all' | 'filled' | 'empty' | string) => void; setApplicationType: (value: 'all' | 'filled' | 'empty') => void; setOrganisation: (value: string | undefined) => void; setHostingType: (value: string | undefined) => void; @@ -40,7 +40,7 @@ const defaultFilters: SearchFilters = { governanceModel: 'all', dynamicsFactor: 'all', complexityFactor: 'all', - applicationCluster: 'all', + applicationSubteam: 'all', applicationType: 'all', organisation: undefined, hostingType: undefined, @@ -88,9 +88,9 @@ export const useSearchStore = create((set) => ({ currentPage: 1, })), - setApplicationCluster: (value) => + setApplicationSubteam: (value) => set((state) => ({ - filters: { ...state.filters, applicationCluster: value }, + filters: { ...state.filters, applicationSubteam: value }, currentPage: 1, })), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a4ea1fd..a22e03e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -26,6 +26,7 @@ export interface ReferenceValue { remarks?: string; // Remarks attribute for Governance Model application?: string; // Application attribute for Governance Model indicators?: string; // Indicators attribute for Business Impact Analyse + teamType?: string; // Type attribute for Team objects (Business, Enabling, Staf) } // Application list item (summary view) @@ -38,7 +39,8 @@ export interface ApplicationListItem { governanceModel: ReferenceValue | null; dynamicsFactor: ReferenceValue | null; complexityFactor: ReferenceValue | null; - applicationCluster: ReferenceValue | null; + applicationSubteam: ReferenceValue | null; + applicationTeam: ReferenceValue | null; applicationType: ReferenceValue | null; platform: ReferenceValue | null; // Reference to parent Platform Application Component requiredEffortApplicationManagement: number | null; // Calculated field @@ -74,7 +76,8 @@ export interface ApplicationDetails { complexityFactor: ReferenceValue | null; numberOfUsers: ReferenceValue | null; governanceModel: ReferenceValue | null; - applicationCluster: ReferenceValue | null; + applicationSubteam: ReferenceValue | null; + applicationTeam: ReferenceValue | null; applicationType: ReferenceValue | null; platform: ReferenceValue | null; // Reference to parent Platform Application Component requiredEffortApplicationManagement: number | null; // Calculated field @@ -92,7 +95,7 @@ export interface SearchFilters { governanceModel?: 'all' | 'filled' | 'empty'; dynamicsFactor?: 'all' | 'filled' | 'empty'; complexityFactor?: 'all' | 'filled' | 'empty'; - applicationCluster?: 'all' | 'filled' | 'empty'; + applicationSubteam?: 'all' | 'filled' | 'empty' | string; // Can be 'all', 'empty', or a specific subteam name applicationType?: 'all' | 'filled' | 'empty'; organisation?: string; hostingType?: string; @@ -168,7 +171,8 @@ export interface PendingChanges { complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue }; numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue }; governanceModel?: { from: ReferenceValue | null; to: ReferenceValue }; - applicationCluster?: { from: ReferenceValue | null; to: ReferenceValue }; + applicationSubteam?: { from: ReferenceValue | null; to: ReferenceValue }; + applicationTeam?: { from: ReferenceValue | null; to: ReferenceValue }; applicationType?: { from: ReferenceValue | null; to: ReferenceValue }; } @@ -189,7 +193,8 @@ export interface ReferenceOptions { numberOfUsers: ReferenceValue[]; governanceModels: ReferenceValue[]; applicationFunctions: ReferenceValue[]; - applicationClusters: ReferenceValue[]; + applicationSubteams: ReferenceValue[]; + applicationTeams: ReferenceValue[]; applicationTypes: ReferenceValue[]; organisations: ReferenceValue[]; hostingTypes: ReferenceValue[]; @@ -220,9 +225,12 @@ export interface ZiraTaxonomy { // Dashboard statistics export interface DashboardStats { - totalApplications: number; + totalApplications: number; // Excluding Closed/Deprecated + totalAllApplications: number; // Including all statuses (for status distribution) classifiedCount: number; unclassifiedCount: number; + withApplicationFunction: number; + applicationFunctionPercentage: number; byStatus: Record; byDomain: Record; byGovernanceModel: Record; @@ -284,8 +292,9 @@ export interface PlatformWithWorkloads { totalEffort: number; // platformEffort + workloadsEffort } -export interface TeamDashboardCluster { - cluster: ReferenceValue | null; +// Subteam level in team dashboard hierarchy +export interface TeamDashboardSubteam { + subteam: ReferenceValue | null; applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload) platforms: PlatformWithWorkloads[]; // Platforms with their workloads totalEffort: number; // Sum of all applications + platforms + workloads @@ -295,17 +304,21 @@ export interface TeamDashboardCluster { byGovernanceModel: Record; // Distribution per governance model } +// Team level in team dashboard hierarchy (contains subteams) +export interface TeamDashboardTeam { + team: ReferenceValue | null; // team.teamType contains "Business", "Enabling", or "Staf" + subteams: TeamDashboardSubteam[]; + // Aggregated KPIs (sum of all subteams) + totalEffort: number; + minEffort: number; + maxEffort: number; + applicationCount: number; + byGovernanceModel: Record; +} + export interface TeamDashboardData { - clusters: TeamDashboardCluster[]; - unassigned: { - applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload) - platforms: PlatformWithWorkloads[]; // Platforms with their workloads - totalEffort: number; // Sum of all applications + platforms + workloads - minEffort: number; // Sum of all minimum FTE values - maxEffort: number; // Sum of all maximum FTE values - applicationCount: number; // Count of all applications (including platforms and workloads) - byGovernanceModel: Record; // Distribution per governance model - }; + teams: TeamDashboardTeam[]; + unassigned: TeamDashboardSubteam; // Apps without team assignment } // Chat message for AI conversation diff --git a/package.json b/package.json index e0b3753..08038d8 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "dev:backend": "npm run dev --workspace=backend", "dev:frontend": "npm run dev --workspace=frontend", "build": "npm run build --workspaces", - "start": "npm run start --workspace=backend" + "start": "npm run start --workspace=backend", + "generate-schema": "npm run generate-schema --workspace=backend" }, "devDependencies": { "concurrently": "^8.2.2"