diff --git a/.env.example b/.env.example index c96b844..647b4f4 100644 --- a/.env.example +++ b/.env.example @@ -37,7 +37,6 @@ DATABASE_URL=postgresql://cmdb:cmdb-dev@localhost:5432/cmdb # Jira Assets Configuration # ----------------------------------------------------------------------------- JIRA_HOST=https://jira.zuyderland.nl -JIRA_SCHEMA_ID=your_schema_id # Jira Service Account Token (for read operations: sync, fetching data) # This token is used for all read operations from Jira Assets. diff --git a/CLAUDE.md b/CLAUDE.md index d089d41..cc3fc36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,8 @@ -# CLAUDE.md - ZiRA Classificatie Tool +# CLAUDE.md - CMDB Insight ## Project Overview -**Project:** ZiRA Classificatie Tool (Zuyderland CMDB Editor) +**Project:** CMDB Insight (Zuyderland CMDB Editor) **Organization:** Zuyderland Medisch Centrum - ICMT **Purpose:** Interactive tool for classifying ~500 application components into ZiRA (Ziekenhuis Referentie Architectuur) application functions with Jira Assets CMDB integration. @@ -18,7 +18,7 @@ The project has a working implementation with: - SQLite database for classification history Key files: -- `zira-classificatie-tool-specificatie.md` - Complete technical specification +- `cmdb-insight-specificatie.md` - Complete technical specification - `zira-taxonomy.json` - ZiRA taxonomy with 90+ application functions across 10 domains - `management-parameters.json` - Reference data for dynamics, complexity, users, governance models @@ -57,7 +57,7 @@ cd frontend && npm run build ## Project Structure ``` -zira-classificatie-tool/ +cmdb-insight/ ├── package.json # Root workspace package ├── docker-compose.yml # Docker development setup ├── .env.example # Environment template @@ -271,9 +271,25 @@ SESSION_SECRET=your_secure_random_string | File | Purpose | |------|---------| -| `zira-classificatie-tool-specificatie.md` | Complete technical specification | +| `cmdb-insight-specificatie.md` | Complete technical specification | | `zira-taxonomy.json` | 90+ ZiRA application functions | | `management-parameters.json` | Dropdown options and reference data | +| `docs/refactor-plan.md` | **Architecture refactoring plan (Phase 1: Analysis)** | + +## Architecture Refactoring + +**Status:** Phase 1 Complete - Analysis and Planning + +A comprehensive refactoring plan has been created to improve maintainability, reduce duplication, and establish clearer separation of concerns. See `docs/refactor-plan.md` for: + +- Current architecture map (files/folders/modules) +- Pain points and duplication analysis +- Target architecture (domain/infrastructure/services/api) +- Migration steps in order +- Explicit deletion list (files to remove later) +- API payload contract and recursion insights + +**⚠️ Note:** Phase 1 is analysis only - no functional changes have been made yet. ## Language diff --git a/azure-pipelines.yml b/azure-pipelines.yml index aaa9b5d..7d0aac9 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -15,7 +15,7 @@ pool: variables: # Azure Container Registry naam - pas aan naar jouw ACR acrName: 'zdlas' - repositoryName: 'zuyderland-cmdb-gui' + repositoryName: 'cmdb-insight' dockerRegistryServiceConnection: 'zuyderland-cmdb-acr-connection' # Service connection naam in Azure DevOps imageTag: '$(Build.BuildId)' diff --git a/backend/package.json b/backend/package.json index ab46044..0e08634 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,9 +9,13 @@ "build": "tsc", "start": "node dist/index.js", "generate-schema": "tsx scripts/generate-schema.ts", + "generate-types": "tsx scripts/generate-types-from-db.ts", + "discover-schema": "tsx scripts/discover-schema.ts", "migrate": "tsx scripts/run-migrations.ts", "check-admin": "tsx scripts/check-admin-user.ts", - "migrate:sqlite-to-postgres": "tsx scripts/migrate-sqlite-to-postgres.ts" + "migrate:sqlite-to-postgres": "tsx scripts/migrate-sqlite-to-postgres.ts", + "migrate:search-enabled": "tsx scripts/migrate-search-enabled.ts", + "setup-schema-mappings": "tsx scripts/setup-schema-mappings.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.32.1", diff --git a/backend/scripts/discover-schema.ts b/backend/scripts/discover-schema.ts new file mode 100644 index 0000000..4361d40 --- /dev/null +++ b/backend/scripts/discover-schema.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env npx tsx + +/** + * Schema Discovery CLI + * + * Manually trigger schema discovery from Jira Assets API. + * This script fetches the schema and stores it in the database. + * + * Usage: npm run discover-schema + */ + +import { schemaDiscoveryService } from '../src/services/schemaDiscoveryService.js'; +import { schemaCacheService } from '../src/services/schemaCacheService.js'; +import { logger } from '../src/services/logger.js'; + +async function main() { + try { + console.log('Starting schema discovery...'); + logger.info('Schema Discovery CLI: Starting manual schema discovery'); + + // Force discovery (ignore cache) + await schemaDiscoveryService.discoverAndStoreSchema(true); + + // Invalidate cache so next request gets fresh data + schemaCacheService.invalidate(); + + console.log('✅ Schema discovery completed successfully!'); + logger.info('Schema Discovery CLI: Schema discovery completed successfully'); + + process.exit(0); + } catch (error) { + console.error('❌ Schema discovery failed:', error); + logger.error('Schema Discovery CLI: Schema discovery failed', error); + process.exit(1); + } +} + +main(); diff --git a/backend/scripts/generate-schema.ts b/backend/scripts/generate-schema.ts index 250bc27..f104d51 100644 --- a/backend/scripts/generate-schema.ts +++ b/backend/scripts/generate-schema.ts @@ -752,18 +752,12 @@ function generateDatabaseSchema(generatedAt: Date): string { '-- =============================================================================', '-- 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', - ');', + '--', + '-- NOTE: This schema is LEGACY and deprecated.', + '-- The current system uses the normalized schema defined in', + '-- backend/src/services/database/normalized-schema.ts', + '--', + '-- This file is kept for reference and migration purposes only.', '', '-- Object relations (references between objects)', 'CREATE TABLE IF NOT EXISTS object_relations (', @@ -787,10 +781,6 @@ function generateDatabaseSchema(generatedAt: Date): string { '-- 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);', diff --git a/backend/scripts/generate-types-from-db.ts b/backend/scripts/generate-types-from-db.ts new file mode 100644 index 0000000..00e6fc0 --- /dev/null +++ b/backend/scripts/generate-types-from-db.ts @@ -0,0 +1,484 @@ +#!/usr/bin/env npx tsx + +/** + * Type Generation Script - Database to TypeScript + * + * Generates TypeScript types from database schema. + * This script reads the schema from the database (object_types, attributes) + * and generates: + * - TypeScript types (jira-types.ts) + * - Schema metadata (jira-schema.ts) + * + * Usage: npm run generate-types + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { createDatabaseAdapter } from '../src/services/database/factory.js'; +import type { AttributeDefinition } from '../src/generated/jira-schema.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const OUTPUT_DIR = path.resolve(__dirname, '../src/generated'); + +interface DatabaseObjectType { + jira_type_id: number; + type_name: string; + display_name: string; + description: string | null; + sync_priority: number; + object_count: number; +} + +interface DatabaseAttribute { + jira_attr_id: number; + object_type_name: string; + attr_name: string; + field_name: string; + attr_type: string; + is_multiple: boolean | number; + is_editable: boolean | number; + is_required: boolean | number; + is_system: boolean | number; + reference_type_name: string | null; + description: string | null; +} + +function generateTypeScriptType(attrType: string, 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 escapeString(str: string): string { + return str.replace(/'/g, "\\'").replace(/\n/g, ' '); +} + +function generateTypesFile(objectTypes: Array<{ + jiraTypeId: number; + name: string; + typeName: string; + objectCount: number; + attributes: AttributeDefinition[]; +}>, generatedAt: Date): string { + const lines: string[] = [ + '// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY', + '// Generated from database schema', + `// Generated at: ${generatedAt.toISOString()}`, + '//', + '// Re-generate with: npm run generate-types', + '', + '// =============================================================================', + '// 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: Array<{ + jiraTypeId: number; + name: string; + typeName: string; + syncPriority: number; + objectCount: number; + attributes: AttributeDefinition[]; +}>, generatedAt: Date): string { + const lines: string[] = [ + '// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY', + '// Generated from database schema', + `// Generated at: ${generatedAt.toISOString()}`, + '//', + '// Re-generate with: npm run generate-types', + '', + '// =============================================================================', + '// 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: '${escapeString(objType.name)}',`); + 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: '${escapeString(attr.name)}', fieldName: '${attr.fieldName}', type: '${attr.type}', isMultiple: ${attr.isMultiple}, isEditable: ${attr.isEditable}, isRequired: ${attr.isRequired}, isSystem: ${attr.isSystem}`; + + if (attr.referenceTypeName) { + attrLine += `, referenceTypeName: '${attr.referenceTypeName}'`; + } + if (attr.description) { + attrLine += `, description: '${escapeString(attr.description)}'`; + } + + 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(` '${escapeString(objType.name)}': '${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'); +} + +async function main() { + const generatedAt = new Date(); + + console.log(''); + console.log('╔════════════════════════════════════════════════════════════════╗'); + console.log('║ Type Generation - Database to TypeScript ║'); + console.log('╚════════════════════════════════════════════════════════════════╝'); + console.log(''); + + try { + // Connect to database + const db = createDatabaseAdapter(); + console.log('✓ Connected to database'); + + // Ensure schema is discovered first + const { schemaDiscoveryService } = await import('../src/services/schemaDiscoveryService.js'); + await schemaDiscoveryService.discoverAndStoreSchema(); + console.log('✓ Schema discovered from database'); + + // Fetch object types + const objectTypeRows = await db.query(` + SELECT * FROM object_types + ORDER BY sync_priority, type_name + `); + console.log(`✓ Fetched ${objectTypeRows.length} object types`); + + // Fetch attributes + const attributeRows = await db.query(` + SELECT * FROM attributes + ORDER BY object_type_name, jira_attr_id + `); + console.log(`✓ Fetched ${attributeRows.length} attributes`); + + // Build object types with attributes + const objectTypes = objectTypeRows.map(typeRow => { + const attributes = attributeRows + .filter(a => a.object_type_name === typeRow.type_name) + .map(attrRow => { + // Convert boolean/number for SQLite compatibility + const isMultiple = typeof attrRow.is_multiple === 'boolean' ? attrRow.is_multiple : attrRow.is_multiple === 1; + const isEditable = typeof attrRow.is_editable === 'boolean' ? attrRow.is_editable : attrRow.is_editable === 1; + const isRequired = typeof attrRow.is_required === 'boolean' ? attrRow.is_required : attrRow.is_required === 1; + const isSystem = typeof attrRow.is_system === 'boolean' ? attrRow.is_system : attrRow.is_system === 1; + + return { + jiraId: attrRow.jira_attr_id, + name: attrRow.attr_name, + fieldName: attrRow.field_name, + type: attrRow.attr_type as AttributeDefinition['type'], + isMultiple, + isEditable, + isRequired, + isSystem, + referenceTypeName: attrRow.reference_type_name || undefined, + description: attrRow.description || undefined, + } as AttributeDefinition; + }); + + return { + jiraTypeId: typeRow.jira_type_id, + name: typeRow.display_name, + typeName: typeRow.type_name, + syncPriority: typeRow.sync_priority, + objectCount: typeRow.object_count, + attributes, + }; + }); + + // Ensure output directory exists + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + } + + // Generate TypeScript types file + const typesContent = generateTypesFile(objectTypes, generatedAt); + const typesPath = path.join(OUTPUT_DIR, 'jira-types.ts'); + fs.writeFileSync(typesPath, typesContent, 'utf-8'); + console.log(`✓ Generated ${typesPath}`); + + // Generate schema file + const schemaContent = generateSchemaFile(objectTypes, generatedAt); + const schemaPath = path.join(OUTPUT_DIR, 'jira-schema.ts'); + fs.writeFileSync(schemaPath, schemaContent, 'utf-8'); + console.log(`✓ Generated ${schemaPath}`); + + console.log(''); + console.log('✅ Type generation completed successfully!'); + console.log(` Generated ${objectTypes.length} object types with ${objectTypes.reduce((sum, ot) => sum + ot.attributes.length, 0)} attributes`); + console.log(''); + + } catch (error) { + console.error(''); + console.error('❌ Type generation failed:', error); + process.exit(1); + } +} + +main(); diff --git a/backend/scripts/migrate-search-enabled.ts b/backend/scripts/migrate-search-enabled.ts new file mode 100644 index 0000000..73959ce --- /dev/null +++ b/backend/scripts/migrate-search-enabled.ts @@ -0,0 +1,90 @@ +/** + * Migration script: Add search_enabled column to schemas table + * + * This script adds the search_enabled column to the schemas table if it doesn't exist. + * + * Usage: + * npm run migrate:search-enabled + * or + * tsx scripts/migrate-search-enabled.ts + */ + +import { getDatabaseAdapter } from '../src/services/database/singleton.js'; +import { logger } from '../src/services/logger.js'; + +async function main() { + try { + console.log('Starting migration: Adding search_enabled column to schemas table...'); + + const db = getDatabaseAdapter(); + await db.ensureInitialized?.(); + + const isPostgres = db.isPostgres === true; + + // Check if column exists and add it if it doesn't + if (isPostgres) { + // PostgreSQL: Check if column exists + const columnExists = await db.queryOne<{ exists: boolean }>(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'schemas' AND column_name = 'search_enabled' + ) as exists + `); + + if (!columnExists?.exists) { + console.log('Adding search_enabled column to schemas table...'); + await db.execute(` + ALTER TABLE schemas ADD COLUMN search_enabled BOOLEAN NOT NULL DEFAULT TRUE; + `); + console.log('✓ Column added successfully'); + } else { + console.log('✓ Column already exists'); + } + + // Create index if it doesn't exist + try { + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_schemas_search_enabled ON schemas(search_enabled); + `); + console.log('✓ Index created/verified'); + } catch (error) { + console.log('Index may already exist, continuing...'); + } + } else { + // SQLite: Try to query the column to see if it exists + try { + await db.queryOne('SELECT search_enabled FROM schemas LIMIT 1'); + console.log('✓ Column already exists'); + } catch { + // Column doesn't exist, add it + console.log('Adding search_enabled column to schemas table...'); + await db.execute('ALTER TABLE schemas ADD COLUMN search_enabled INTEGER NOT NULL DEFAULT 1'); + console.log('✓ Column added successfully'); + } + + // Create index if it doesn't exist + try { + await db.execute('CREATE INDEX IF NOT EXISTS idx_schemas_search_enabled ON schemas(search_enabled)'); + console.log('✓ Index created/verified'); + } catch (error) { + console.log('Index may already exist, continuing...'); + } + } + + // Verify the column exists + try { + await db.queryOne('SELECT search_enabled FROM schemas LIMIT 1'); + console.log('✓ Migration completed successfully - search_enabled column verified'); + } catch (error) { + console.error('✗ Migration verification failed:', error); + process.exit(1); + } + + process.exit(0); + } catch (error) { + console.error('✗ Migration failed:', error); + process.exit(1); + } +} + +main(); diff --git a/backend/scripts/migrate-sqlite-to-postgres.ts b/backend/scripts/migrate-sqlite-to-postgres.ts index e0d997a..cd3c2f2 100644 --- a/backend/scripts/migrate-sqlite-to-postgres.ts +++ b/backend/scripts/migrate-sqlite-to-postgres.ts @@ -66,7 +66,8 @@ async function migrateCacheDatabase(pg: Pool) { const sqlite = new Database(SQLITE_CACHE_DB, { readonly: true }); try { - // Migrate cached_objects + // Migrate cached_objects (LEGACY - only for migrating old data from deprecated schema) + // Note: New databases use the normalized schema (objects + attribute_values tables) const objects = sqlite.prepare('SELECT * FROM cached_objects').all() as any[]; console.log(` Migrating ${objects.length} cached objects...`); diff --git a/backend/scripts/setup-schema-mappings.ts b/backend/scripts/setup-schema-mappings.ts new file mode 100644 index 0000000..693285d --- /dev/null +++ b/backend/scripts/setup-schema-mappings.ts @@ -0,0 +1,178 @@ +/** + * Setup Schema Mappings Script + * + * Configures schema mappings for object types based on the provided configuration. + * Run with: npm run setup-schema-mappings + */ + +import { schemaMappingService } from '../src/services/schemaMappingService.js'; +import { logger } from '../src/services/logger.js'; +import { JIRA_NAME_TO_TYPE } from '../src/generated/jira-schema.js'; + +// Configuration: Schema ID -> Array of object type display names +const SCHEMA_MAPPINGS: Record = { + '8': ['User'], + '6': [ + 'Application Component', + 'Flows', + 'Server', + 'AzureSubscription', + 'Certificate', + 'Domain', + 'Package', + 'PackageBuild', + 'Privileged User', + 'Software', + 'SoftwarePatch', + 'Supplier', + 'Application Management - Subteam', + 'Application Management - Team', + 'Measures', + 'Rebootgroups', + 'Application Management - Hosting', + 'Application Management - Number of Users', + 'Application Management - TAM', + 'Application Management - Application Type', + 'Application Management - Complexity Factor', + 'Application Management - Dynamics Factor', + 'ApplicationFunction', + 'ApplicationFunctionCategory', + 'Business Impact Analyse', + 'Business Importance', + 'Certificate ClassificationType', + 'Certificate Type', + 'Hosting Type', + 'ICT Governance Model', + 'Organisation', + ], +}; + +async function setupSchemaMappings() { + logger.info('Setting up schema mappings...'); + + try { + let totalMappings = 0; + let skippedMappings = 0; + let errors = 0; + + for (const [schemaId, objectTypeNames] of Object.entries(SCHEMA_MAPPINGS)) { + logger.info(`\nConfiguring schema ${schemaId} with ${objectTypeNames.length} object types...`); + + for (const displayName of objectTypeNames) { + try { + // Convert display name to typeName + let typeName: string; + + if (displayName === 'User') { + // User might not be in the generated schema, use 'User' directly + typeName = 'User'; + + // First, ensure User exists in object_types table + const { normalizedCacheStore } = await import('../src/services/normalizedCacheStore.js'); + const db = (normalizedCacheStore as any).db; + await db.ensureInitialized?.(); + + // Check if User exists in object_types + const existing = await db.queryOne<{ type_name: string }>(` + SELECT type_name FROM object_types WHERE type_name = ? + `, [typeName]); + + if (!existing) { + // Insert User into object_types (we'll use a placeholder jira_type_id) + // The actual jira_type_id will be discovered during schema discovery + logger.info(` ℹ️ Adding "User" to object_types table...`); + try { + await db.execute(` + INSERT INTO object_types (jira_type_id, type_name, display_name, description, sync_priority, object_count, discovered_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(jira_type_id) DO NOTHING + `, [ + 999999, // Placeholder ID - will be updated during schema discovery + 'User', + 'User', + 'User object type from schema 8', + 0, + 0, + new Date().toISOString(), + new Date().toISOString() + ]); + + // Also try with type_name as unique constraint + await db.execute(` + INSERT INTO object_types (jira_type_id, type_name, display_name, description, sync_priority, object_count, discovered_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(type_name) DO UPDATE SET + display_name = excluded.display_name, + updated_at = excluded.updated_at + `, [ + 999999, + 'User', + 'User', + 'User object type from schema 8', + 0, + 0, + new Date().toISOString(), + new Date().toISOString() + ]); + logger.info(` ✓ Added "User" to object_types table`); + } catch (error: any) { + // If it already exists, that's fine + if (error.message?.includes('UNIQUE constraint') || error.message?.includes('duplicate key')) { + logger.info(` ℹ️ "User" already exists in object_types table`); + } else { + throw error; + } + } + } + } else { + // Look up typeName from JIRA_NAME_TO_TYPE mapping + typeName = JIRA_NAME_TO_TYPE[displayName]; + + if (!typeName) { + logger.warn(` ⚠️ Skipping "${displayName}" - typeName not found in schema`); + skippedMappings++; + continue; + } + } + + // Set the mapping + await schemaMappingService.setMapping(typeName, schemaId, true); + logger.info(` ✓ Mapped ${typeName} (${displayName}) -> Schema ${schemaId}`); + totalMappings++; + + } catch (error) { + logger.error(` ✗ Failed to map "${displayName}" to schema ${schemaId}:`, error); + errors++; + } + } + } + + logger.info(`\n✅ Schema mappings setup complete!`); + logger.info(` - Total mappings created: ${totalMappings}`); + if (skippedMappings > 0) { + logger.info(` - Skipped (not found in schema): ${skippedMappings}`); + } + if (errors > 0) { + logger.info(` - Errors: ${errors}`); + } + + // Clear cache to ensure fresh lookups + schemaMappingService.clearCache(); + logger.info(`\n💾 Cache cleared - mappings are now active`); + + } catch (error) { + logger.error('Failed to setup schema mappings:', error); + process.exit(1); + } +} + +// Run the script +setupSchemaMappings() + .then(() => { + logger.info('\n✨ Done!'); + process.exit(0); + }) + .catch((error) => { + logger.error('Script failed:', error); + process.exit(1); + }); diff --git a/backend/src/api/controllers/DebugController.ts b/backend/src/api/controllers/DebugController.ts new file mode 100644 index 0000000..e547500 --- /dev/null +++ b/backend/src/api/controllers/DebugController.ts @@ -0,0 +1,533 @@ +/** + * DebugController - Debug/testing endpoints for architecture validation + * + * Provides endpoints to run SQL queries and check database state for testing. + */ + +import { Request, Response } from 'express'; +import { logger } from '../../services/logger.js'; +import { getServices } from '../../services/ServiceFactory.js'; + +export class DebugController { + /** + * Execute a SQL query (read-only for safety) + * POST /api/v2/debug/query + * Body: { sql: string, params?: any[] } + */ + async executeQuery(req: Request, res: Response): Promise { + try { + const { sql, params = [] } = req.body; + + if (!sql || typeof sql !== 'string') { + res.status(400).json({ error: 'SQL query required in request body' }); + return; + } + + // Safety check: only allow SELECT queries + const normalizedSql = sql.trim().toUpperCase(); + if (!normalizedSql.startsWith('SELECT')) { + res.status(400).json({ error: 'Only SELECT queries are allowed for security' }); + return; + } + + const services = getServices(); + const db = services.cacheRepo.db; + + const result = await db.query(sql, params); + + res.json({ + success: true, + result, + rowCount: result.length, + }); + } catch (error) { + logger.error('DebugController: Query execution failed', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + /** + * Get object info (ID, key, type) for debugging + * GET /api/v2/debug/objects?objectKey=... + */ + async getObjectInfo(req: Request, res: Response): Promise { + try { + const objectKey = req.query.objectKey as string; + if (!objectKey) { + res.status(400).json({ error: 'objectKey query parameter required' }); + return; + } + + const services = getServices(); + const obj = await services.cacheRepo.getObjectByKey(objectKey); + + if (!obj) { + res.status(404).json({ error: 'Object not found' }); + return; + } + + // Get attribute count + const attrValues = await services.cacheRepo.getAttributeValues(obj.id); + + res.json({ + object: obj, + attributeValueCount: attrValues.length, + }); + } catch (error) { + logger.error('DebugController: Failed to get object info', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + /** + * Get relation info for debugging + * GET /api/v2/debug/relations?objectKey=... + */ + async getRelationInfo(req: Request, res: Response): Promise { + try { + const objectKey = req.query.objectKey as string; + if (!objectKey) { + res.status(400).json({ error: 'objectKey query parameter required' }); + return; + } + + const services = getServices(); + const obj = await services.cacheRepo.getObjectByKey(objectKey); + + if (!obj) { + res.status(404).json({ error: 'Object not found' }); + return; + } + + // Get relations where this object is source + const sourceRelations = await services.cacheRepo.db.query<{ + sourceId: string; + targetId: string; + attributeId: number; + sourceType: string; + targetType: string; + }>( + `SELECT source_id as sourceId, target_id as targetId, attribute_id as attributeId, + source_type as sourceType, target_type as targetType + FROM object_relations + WHERE source_id = ?`, + [obj.id] + ); + + // Get relations where this object is target + const targetRelations = await services.cacheRepo.db.query<{ + sourceId: string; + targetId: string; + attributeId: number; + sourceType: string; + targetType: string; + }>( + `SELECT source_id as sourceId, target_id as targetId, attribute_id as attributeId, + source_type as sourceType, target_type as targetType + FROM object_relations + WHERE target_id = ?`, + [obj.id] + ); + + res.json({ + object: obj, + sourceRelations: sourceRelations.length, + targetRelations: targetRelations.length, + relations: { + outgoing: sourceRelations, + incoming: targetRelations, + }, + }); + } catch (error) { + logger.error('DebugController: Failed to get relation info', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + /** + * Get object type statistics + * GET /api/v2/debug/object-types/:typeName/stats + */ + async getObjectTypeStats(req: Request, res: Response): Promise { + try { + const typeName = req.params.typeName; + const services = getServices(); + + // Get object count + const count = await services.cacheRepo.countObjectsByType(typeName); + + // Get sample objects + const samples = await services.cacheRepo.getObjectsByType(typeName, { limit: 5 }); + + // Get enabled status from schema + const typeInfo = await services.schemaRepo.getObjectTypeByTypeName(typeName); + + res.json({ + typeName, + objectCount: count, + enabled: typeInfo?.enabled || false, + sampleObjects: samples.map(o => ({ + id: o.id, + objectKey: o.objectKey, + label: o.label, + })), + }); + } catch (error) { + logger.error('DebugController: Failed to get object type stats', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + /** + * Get all object types with their enabled status (for debugging) + * GET /api/v2/debug/all-object-types + */ + async getAllObjectTypes(req: Request, res: Response): Promise { + try { + const services = getServices(); + const db = services.schemaRepo.db; + + // Check if object_types table exists + try { + const tableCheck = await db.query('SELECT 1 FROM object_types LIMIT 1'); + } catch (error) { + logger.error('DebugController: object_types table does not exist or is not accessible', error); + res.status(500).json({ + error: 'object_types table does not exist. Please run schema sync first.', + details: error instanceof Error ? error.message : 'Unknown error', + }); + return; + } + + // Get all object types + let allTypes: Array<{ + id: number; + type_name: string | null; + display_name: string; + enabled: boolean | number; + jira_type_id: number; + schema_id: number; + }>; + + try { + allTypes = await db.query<{ + id: number; + type_name: string | null; + display_name: string; + enabled: boolean | number; + jira_type_id: number; + schema_id: number; + }>( + `SELECT id, type_name, display_name, enabled, jira_type_id, schema_id + FROM object_types + ORDER BY enabled DESC, type_name` + ); + } catch (error) { + logger.error('DebugController: Failed to query object_types table', error); + res.status(500).json({ + error: 'Failed to query object_types table', + details: error instanceof Error ? error.message : 'Unknown error', + }); + return; + } + + // Get enabled types via service (may fail if table has issues) + let enabledTypes: Array<{ typeName: string; displayName: string; schemaId: string; objectTypeId: number }> = []; + try { + enabledTypes = await services.schemaSyncService.getEnabledObjectTypes(); + logger.debug(`DebugController: getEnabledObjectTypes returned ${enabledTypes.length} types: ${enabledTypes.map(t => t.typeName).join(', ')}`); + } catch (error) { + logger.error('DebugController: Failed to get enabled types via service', error); + if (error instanceof Error) { + logger.error('Error details:', { message: error.message, stack: error.stack }); + } + // Continue without enabled types from service + } + + res.json({ + allTypes: allTypes.map(t => ({ + id: t.id, + typeName: t.type_name, + displayName: t.display_name, + enabled: t.enabled, + jiraTypeId: t.jira_type_id, + schemaId: t.schema_id, + hasTypeName: !!(t.type_name && t.type_name.trim() !== ''), + })), + enabledTypes: enabledTypes.map(t => ({ + typeName: t.typeName, + displayName: t.displayName, + schemaId: t.schemaId, + objectTypeId: t.objectTypeId, + })), + summary: { + total: allTypes.length, + enabled: allTypes.filter(t => { + const isPostgres = db.isPostgres === true; + const enabledValue = isPostgres ? (t.enabled === true) : (t.enabled === 1); + return enabledValue && t.type_name && t.type_name.trim() !== ''; + }).length, + enabledWithTypeName: enabledTypes.length, + missingTypeName: allTypes.filter(t => !t.type_name || t.type_name.trim() === '').length, + }, + }); + } catch (error) { + logger.error('DebugController: Failed to get all object types', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + /** + * Diagnose a specific object type (check database state) + * GET /api/v2/debug/object-types/diagnose/:typeName + * Checks both by type_name and display_name + */ + async diagnoseObjectType(req: Request, res: Response): Promise { + try { + const typeName = req.params.typeName; + const services = getServices(); + const db = services.schemaRepo.db; + const isPostgres = db.isPostgres === true; + const enabledCondition = isPostgres ? 'enabled IS true' : 'enabled = 1'; + + // Check by type_name (exact match) + const byTypeName = await db.query<{ + id: number; + schema_id: number; + jira_type_id: number; + type_name: string | null; + display_name: string; + enabled: boolean | number; + description: string | null; + }>( + `SELECT id, schema_id, jira_type_id, type_name, display_name, enabled, description + FROM object_types + WHERE type_name = ?`, + [typeName] + ); + + // Check by display_name (case-insensitive, partial match) + const byDisplayName = await db.query<{ + id: number; + schema_id: number; + jira_type_id: number; + type_name: string | null; + display_name: string; + enabled: boolean | number; + description: string | null; + }>( + isPostgres + ? `SELECT id, schema_id, jira_type_id, type_name, display_name, enabled, description + FROM object_types + WHERE LOWER(display_name) LIKE LOWER(?)` + : `SELECT id, schema_id, jira_type_id, type_name, display_name, enabled, description + FROM object_types + WHERE LOWER(display_name) LIKE LOWER(?)`, + [`%${typeName}%`] + ); + + // Get schema info for found types + const schemaIds = [...new Set([...byTypeName.map(t => t.schema_id), ...byDisplayName.map(t => t.schema_id)])]; + const schemas = schemaIds.length > 0 + ? await db.query<{ id: number; jira_schema_id: string; name: string }>( + `SELECT id, jira_schema_id, name FROM schemas WHERE id IN (${schemaIds.map(() => '?').join(',')})`, + schemaIds + ) + : []; + + const schemaMap = new Map(schemas.map(s => [s.id, s])); + + // Check enabled types via service + let enabledTypesFromService: string[] = []; + try { + const enabledTypes = await services.schemaSyncService.getEnabledObjectTypes(); + enabledTypesFromService = enabledTypes.map(t => t.typeName); + } catch (error) { + logger.error('DebugController: Failed to get enabled types from service', error); + } + + // Check if type is in enabled list from service + const isInEnabledList = enabledTypesFromService.includes(typeName); + + res.json({ + requestedType: typeName, + foundByTypeName: byTypeName.map(t => ({ + id: t.id, + schemaId: t.schema_id, + jiraSchemaId: schemaMap.get(t.schema_id)?.jira_schema_id, + schemaName: schemaMap.get(t.schema_id)?.name, + jiraTypeId: t.jira_type_id, + typeName: t.type_name, + displayName: t.display_name, + enabled: t.enabled, + enabledValue: isPostgres ? (t.enabled === true) : (t.enabled === 1), + hasTypeName: !!(t.type_name && t.type_name.trim() !== ''), + description: t.description, + })), + foundByDisplayName: byDisplayName.filter(t => !byTypeName.some(t2 => t2.id === t.id)).map(t => ({ + id: t.id, + schemaId: t.schema_id, + jiraSchemaId: schemaMap.get(t.schema_id)?.jira_schema_id, + schemaName: schemaMap.get(t.schema_id)?.name, + jiraTypeId: t.jira_type_id, + typeName: t.type_name, + displayName: t.display_name, + enabled: t.enabled, + enabledValue: isPostgres ? (t.enabled === true) : (t.enabled === 1), + hasTypeName: !!(t.type_name && t.type_name.trim() !== ''), + description: t.description, + })), + diagnosis: { + found: byTypeName.length > 0 || byDisplayName.length > 0, + foundExact: byTypeName.length > 0, + foundByDisplay: byDisplayName.length > 0, + isEnabled: byTypeName.length > 0 + ? (isPostgres ? (byTypeName[0].enabled === true) : (byTypeName[0].enabled === 1)) + : byDisplayName.length > 0 + ? (isPostgres ? (byDisplayName[0].enabled === true) : (byDisplayName[0].enabled === 1)) + : false, + hasTypeName: byTypeName.length > 0 + ? !!(byTypeName[0].type_name && byTypeName[0].type_name.trim() !== '') + : byDisplayName.length > 0 + ? !!(byDisplayName[0].type_name && byDisplayName[0].type_name.trim() !== '') + : false, + isInEnabledList, + issue: !isInEnabledList && (byTypeName.length > 0 || byDisplayName.length > 0) + ? (byTypeName.length > 0 && !(byTypeName[0].type_name && byTypeName[0].type_name.trim() !== '') + ? 'Type is enabled in database but has missing type_name (will be filtered out)' + : byTypeName.length > 0 && !(isPostgres ? (byTypeName[0].enabled === true) : (byTypeName[0].enabled === 1)) + ? 'Type exists but is not enabled in database' + : 'Type exists but not found in enabled list (may have missing type_name)') + : !isInEnabledList && byTypeName.length === 0 && byDisplayName.length === 0 + ? 'Type not found in database' + : 'No issues detected', + }, + enabledTypesCount: enabledTypesFromService.length, + enabledTypesList: enabledTypesFromService, + }); + } catch (error) { + logger.error(`DebugController: Failed to diagnose object type ${req.params.typeName}`, error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + /** + * Fix object types with missing type_name + * POST /api/v2/debug/fix-missing-type-names + * This will try to fix object types that have NULL type_name by looking up by display_name + */ + async fixMissingTypeNames(req: Request, res: Response): Promise { + try { + const services = getServices(); + const db = services.schemaRepo.db; + + // Find all object types with NULL or empty type_name + // Also check for enabled ones specifically + const isPostgres = db.isPostgres === true; + const enabledCondition = isPostgres ? 'enabled IS true' : 'enabled = 1'; + + const brokenTypes = await db.query<{ + id: number; + jira_type_id: number; + display_name: string; + type_name: string | null; + enabled: boolean | number; + }>( + `SELECT id, jira_type_id, display_name, type_name, enabled + FROM object_types + WHERE (type_name IS NULL OR type_name = '') + ORDER BY enabled DESC, display_name` + ); + + // Also check enabled types specifically + const enabledWithNullTypeName = await db.query<{ + id: number; + jira_type_id: number; + display_name: string; + type_name: string | null; + enabled: boolean | number; + }>( + `SELECT id, jira_type_id, display_name, type_name, enabled + FROM object_types + WHERE (type_name IS NULL OR type_name = '') AND ${enabledCondition}` + ); + + if (enabledWithNullTypeName.length > 0) { + logger.warn(`DebugController: Found ${enabledWithNullTypeName.length} ENABLED object types with missing type_name: ${enabledWithNullTypeName.map(t => t.display_name).join(', ')}`); + } + + logger.info(`DebugController: Found ${brokenTypes.length} object types with missing type_name`); + + const fixes: Array<{ id: number; displayName: string; fixedTypeName: string }> = []; + const errors: Array<{ id: number; error: string }> = []; + + for (const broken of brokenTypes) { + try { + // Generate type_name from display_name using toPascalCase + const { toPascalCase } = await import('../../services/schemaUtils.js'); + const fixedTypeName = toPascalCase(broken.display_name); + + if (!fixedTypeName || fixedTypeName.trim() === '') { + errors.push({ + id: broken.id, + error: `Could not generate type_name from display_name: "${broken.display_name}"`, + }); + continue; + } + + // Update the record + await db.execute( + `UPDATE object_types SET type_name = ?, updated_at = ? WHERE id = ?`, + [fixedTypeName, new Date().toISOString(), broken.id] + ); + + fixes.push({ + id: broken.id, + displayName: broken.display_name, + fixedTypeName, + }); + + logger.info(`DebugController: Fixed object type id=${broken.id}, display_name="${broken.display_name}" -> type_name="${fixedTypeName}"`); + } catch (error) { + errors.push({ + id: broken.id, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + // Re-fetch enabled types to verify fix (reuse services from line 294) + const enabledTypesAfterFix = await services.schemaSyncService.getEnabledObjectTypes(); + + res.json({ + success: true, + fixed: fixes.length, + errors: errors.length, + fixes, + errors: errors.length > 0 ? errors : undefined, + enabledTypesAfterFix: enabledTypesAfterFix.map(t => t.typeName), + note: enabledWithNullTypeName.length > 0 + ? `Fixed ${enabledWithNullTypeName.length} enabled types that were missing type_name. They should now appear in enabled types list.` + : undefined, + }); + } catch (error) { + logger.error('DebugController: Failed to fix missing type names', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } +} diff --git a/backend/src/api/controllers/HealthController.ts b/backend/src/api/controllers/HealthController.ts new file mode 100644 index 0000000..1a94d63 --- /dev/null +++ b/backend/src/api/controllers/HealthController.ts @@ -0,0 +1,54 @@ +/** + * HealthController - API health check endpoint + * + * Public endpoint (no auth required) to check if V2 API is working. + */ + +import { Request, Response } from 'express'; +import { logger } from '../../services/logger.js'; +import { getServices } from '../../services/ServiceFactory.js'; + +export class HealthController { + /** + * Health check endpoint + * GET /api/v2/health + */ + async health(req: Request, res: Response): Promise { + try { + const services = getServices(); + + // Check if services are initialized + const isInitialized = !!services.queryService; + + // Check database connection (simple query) + let dbConnected = false; + try { + await services.schemaRepo.getAllSchemas(); + dbConnected = true; + } catch (error) { + logger.warn('V2 Health: Database connection check failed', error); + } + + res.json({ + status: 'ok', + apiVersion: 'v2', + timestamp: new Date().toISOString(), + services: { + initialized: isInitialized, + database: dbConnected ? 'connected' : 'disconnected', + }, + featureFlag: { + useV2Api: process.env.USE_V2_API === 'true', + }, + }); + } catch (error) { + logger.error('V2 Health: Health check failed', error); + res.status(500).json({ + status: 'error', + apiVersion: 'v2', + timestamp: new Date().toISOString(), + error: 'Health check failed', + }); + } + } +} diff --git a/backend/src/api/controllers/ObjectsController.ts b/backend/src/api/controllers/ObjectsController.ts new file mode 100644 index 0000000..1f02d3e --- /dev/null +++ b/backend/src/api/controllers/ObjectsController.ts @@ -0,0 +1,176 @@ +/** + * ObjectsController - API handlers for object operations + * + * NO SQL, NO parsing - delegates to services. + */ + +import { Request, Response } from 'express'; +import { logger } from '../../services/logger.js'; +import { getServices } from '../../services/ServiceFactory.js'; +import type { CMDBObject, CMDBObjectTypeName } from '../../generated/jira-types.js'; +import { getParamString, getQueryString, getQueryNumber } from '../../utils/queryHelpers.js'; + +export class ObjectsController { + /** + * Get a single object by ID or objectKey + * GET /api/v2/objects/:type/:id?refresh=true + * Supports both object ID and objectKey (checks objectKey if ID lookup fails) + */ + async getObject(req: Request, res: Response): Promise { + try { + const type = getParamString(req, 'type'); + const idOrKey = getParamString(req, 'id'); + const forceRefresh = getQueryString(req, 'refresh') === 'true'; + + const services = getServices(); + + // Try to find object ID if idOrKey might be an objectKey + let objectId = idOrKey; + let objRecord = await services.cacheRepo.getObject(idOrKey); + if (!objRecord) { + // Try as objectKey + objRecord = await services.cacheRepo.getObjectByKey(idOrKey); + if (objRecord) { + objectId = objRecord.id; + } + } + + // Force refresh if requested + if (forceRefresh && objectId) { + const enabledTypes = await services.schemaRepo.getEnabledObjectTypes(); + const enabledTypeSet = new Set(enabledTypes.map(t => t.typeName)); + + const refreshResult = await services.refreshService.refreshObject(objectId, enabledTypeSet); + if (!refreshResult.success) { + res.status(500).json({ error: refreshResult.error || 'Failed to refresh object' }); + return; + } + } + + // Get from cache + if (!objectId) { + res.status(404).json({ error: 'Object not found (by ID or key)' }); + return; + } + + const object = await services.queryService.getObject(type as CMDBObjectTypeName, objectId); + + if (!object) { + res.status(404).json({ error: 'Object not found' }); + return; + } + + res.json(object); + } catch (error) { + logger.error('ObjectsController: Failed to get object', error); + res.status(500).json({ error: 'Failed to get object' }); + } + } + + /** + * Get all objects of a type + * GET /api/v2/objects/:type?limit=100&offset=0&search=term + */ + async getObjects(req: Request, res: Response): Promise { + try { + const type = getParamString(req, 'type'); + const limit = getQueryNumber(req, 'limit', 1000); + const offset = getQueryNumber(req, 'offset', 0); + const search = getQueryString(req, 'search'); + + const services = getServices(); + + logger.info(`ObjectsController.getObjects: Querying for type="${type}" with limit=${limit}, offset=${offset}, search=${search || 'none'}`); + + let objects: CMDBObject[]; + if (search) { + objects = await services.queryService.searchByLabel( + type as CMDBObjectTypeName, + search, + { limit, offset } + ); + } else { + objects = await services.queryService.getObjects( + type as CMDBObjectTypeName, + { limit, offset } + ); + } + + const totalCount = await services.queryService.countObjects(type as CMDBObjectTypeName); + + logger.info(`ObjectsController.getObjects: Found ${objects.length} objects of type "${type}" (total count: ${totalCount})`); + + // If no objects found, provide diagnostic information + if (objects.length === 0) { + // Check what object types actually exist in the database + const db = services.cacheRepo.db; + try { + const availableTypes = await db.query<{ object_type_name: string; count: number }>( + `SELECT object_type_name, COUNT(*) as count + FROM objects + GROUP BY object_type_name + ORDER BY count DESC + LIMIT 10` + ); + + if (availableTypes.length > 0) { + logger.warn(`ObjectsController.getObjects: No objects found for type "${type}". Available types in database:`, { + requestedType: type, + availableTypes: availableTypes.map(t => ({ typeName: t.object_type_name, count: t.count })), + }); + } + } catch (error) { + logger.debug('ObjectsController.getObjects: Failed to query available types', error); + } + } + + res.json({ + objectType: type, + objects, + count: objects.length, + totalCount, + offset, + limit, + }); + } catch (error) { + logger.error('ObjectsController: Failed to get objects', error); + res.status(500).json({ error: 'Failed to get objects' }); + } + } + + /** + * Update an object + * PUT /api/v2/objects/:type/:id + */ + async updateObject(req: Request, res: Response): Promise { + try { + const type = getParamString(req, 'type'); + const id = getParamString(req, 'id'); + const updates = req.body as Record; + + const services = getServices(); + + const result = await services.writeThroughService.updateObject( + type as CMDBObjectTypeName, + id, + updates + ); + + if (!result.success) { + res.status(400).json({ error: result.error || 'Failed to update object' }); + return; + } + + // Fetch updated object + const updated = await services.queryService.getObject( + type as CMDBObjectTypeName, + id + ); + + res.json(updated || { success: true }); + } catch (error) { + logger.error('ObjectsController: Failed to update object', error); + res.status(500).json({ error: 'Failed to update object' }); + } + } +} diff --git a/backend/src/api/controllers/SyncController.ts b/backend/src/api/controllers/SyncController.ts new file mode 100644 index 0000000..724ae16 --- /dev/null +++ b/backend/src/api/controllers/SyncController.ts @@ -0,0 +1,277 @@ +/** + * SyncController - API handlers for sync operations + */ + +import { Request, Response } from 'express'; +import { logger } from '../../services/logger.js'; +import { getServices } from '../../services/ServiceFactory.js'; + +export class SyncController { + /** + * Sync all schemas + * POST /api/v2/sync/schemas + */ + async syncSchemas(req: Request, res: Response): Promise { + try { + const services = getServices(); + const result = await services.schemaSyncService.syncAllSchemas(); + + res.json({ + success: true, + ...result, + }); + } catch (error) { + logger.error('SyncController: Failed to sync schemas', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + /** + * Sync all enabled object types + * POST /api/v2/sync/objects + */ + async syncAllObjects(req: Request, res: Response): Promise { + try { + const services = getServices(); + + // Get enabled types + const enabledTypes = await services.schemaSyncService.getEnabledObjectTypes(); + + if (enabledTypes.length === 0) { + res.status(400).json({ + success: false, + error: 'No object types enabled for syncing. Please configure object types in Schema Configuration.', + }); + return; + } + + const results = []; + let totalObjectsProcessed = 0; + let totalObjectsCached = 0; + let totalRelations = 0; + + // Sync each enabled type + for (const type of enabledTypes) { + const result = await services.objectSyncService.syncObjectType( + type.schemaId, + type.objectTypeId, + type.typeName, + type.displayName + ); + + results.push({ + typeName: type.typeName, + displayName: type.displayName, + ...result, + }); + + totalObjectsProcessed += result.objectsProcessed; + totalObjectsCached += result.objectsCached; + totalRelations += result.relationsExtracted; + } + + res.json({ + success: true, + stats: results, + totalObjectsProcessed, + totalObjectsCached, + totalRelations, + }); + } catch (error) { + logger.error('SyncController: Failed to sync objects', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + /** + * Sync a specific object type + * POST /api/v2/sync/objects/:typeName + */ + async syncObjectType(req: Request, res: Response): Promise { + try { + const typeName = req.params.typeName; + const services = getServices(); + + // Get enabled types + let enabledTypes = await services.schemaSyncService.getEnabledObjectTypes(); + + // Filter out any entries with missing typeName + enabledTypes = enabledTypes.filter(t => t && t.typeName); + + // Debug logging - also check database directly + logger.info(`SyncController: Looking for type "${typeName}" in ${enabledTypes.length} enabled types`); + logger.debug(`SyncController: Enabled types: ${JSON.stringify(enabledTypes.map(t => ({ typeName: t?.typeName, displayName: t?.displayName })))}`); + + // Additional debug: Check database directly for enabled types (including those with missing type_name) + const db = services.schemaRepo.db; + const isPostgres = db.isPostgres === true; + const enabledCondition = isPostgres ? 'enabled IS true' : 'enabled = 1'; + const dbCheck = await db.query<{ type_name: string | null; display_name: string; enabled: boolean | number; id: number; jira_type_id: number }>( + `SELECT id, jira_type_id, type_name, display_name, enabled FROM object_types WHERE ${enabledCondition}` + ); + logger.info(`SyncController: Found ${dbCheck.length} enabled types in database (raw check)`); + logger.debug(`SyncController: Database enabled types (raw): ${JSON.stringify(dbCheck.map(t => ({ id: t.id, displayName: t.display_name, typeName: t.type_name, hasTypeName: !!(t.type_name && t.type_name.trim() !== '') })))}`); + + // Check if AzureSubscription or similar is enabled but missing type_name + const matchingByDisplayName = dbCheck.filter(t => + t.display_name.toLowerCase().includes(typeName.toLowerCase()) || + typeName.toLowerCase().includes(t.display_name.toLowerCase()) + ); + if (matchingByDisplayName.length > 0) { + logger.warn(`SyncController: Found enabled type(s) matching "${typeName}" by display_name but not in enabled list:`, { + matches: matchingByDisplayName.map(t => ({ + id: t.id, + displayName: t.display_name, + typeName: t.type_name, + hasTypeName: !!(t.type_name && t.type_name.trim() !== ''), + enabled: t.enabled, + })), + }); + } + + const type = enabledTypes.find(t => t && t.typeName === typeName); + + if (!type) { + // Check if type exists but is not enabled or has missing type_name + const allType = await services.schemaRepo.getObjectTypeByTypeName(typeName); + if (allType) { + // Debug: Check the actual enabled value and query + const enabledValue = allType.enabled; + const enabledType = typeof enabledValue; + logger.warn(`SyncController: Type "${typeName}" found but not in enabled list. enabled=${enabledValue} (type: ${enabledType}), enabledTypes.length=${enabledTypes.length}`); + logger.debug(`SyncController: Enabled types details: ${JSON.stringify(enabledTypes)}`); + + // Try to find it with different case (handle undefined typeName) + const caseInsensitiveMatch = enabledTypes.find(t => t && t.typeName && t.typeName.toLowerCase() === typeName.toLowerCase()); + if (caseInsensitiveMatch) { + logger.warn(`SyncController: Found type with different case: "${caseInsensitiveMatch.typeName}" vs "${typeName}"`); + // Use the found type with correct case + const result = await services.objectSyncService.syncObjectType( + caseInsensitiveMatch.schemaId, + caseInsensitiveMatch.objectTypeId, + caseInsensitiveMatch.typeName, + caseInsensitiveMatch.displayName + ); + res.json({ + success: true, + ...result, + hasErrors: result.errors.length > 0, + note: `Type name case corrected: "${typeName}" -> "${caseInsensitiveMatch.typeName}"`, + }); + return; + } + + // Direct SQL query to verify enabled status and type_name + const db = services.schemaRepo.db; + const isPostgres = db.isPostgres === true; + const rawCheck = await db.queryOne<{ enabled: boolean | number; type_name: string | null; display_name: string }>( + `SELECT enabled, type_name, display_name FROM object_types WHERE type_name = ?`, + [typeName] + ); + + // Check if type is enabled but missing type_name in enabled list (might be filtered out) + const enabledCondition = isPostgres ? 'enabled IS true' : 'enabled = 1'; + const enabledWithMissingTypeName = await db.query<{ display_name: string; type_name: string | null; enabled: boolean | number }>( + `SELECT display_name, type_name, enabled FROM object_types WHERE display_name ILIKE ? AND ${enabledCondition}`, + [`%${typeName}%`] + ); + + // Get list of all enabled type names for better error message + const enabledTypeNames = enabledTypes.map(t => t.typeName).filter(Boolean); + + // Check if the issue is that the type is enabled but has a missing type_name + if (rawCheck && (rawCheck.enabled === true || rawCheck.enabled === 1)) { + if (!rawCheck.type_name || rawCheck.type_name.trim() === '') { + res.status(400).json({ + success: false, + error: `Object type "${typeName}" is enabled in the database but has a missing or empty type_name. This prevents it from being synced. Please run schema sync again to fix the type_name, or use the "Fix Missing Type Names" debug tool (Settings → Debug).`, + details: { + requestedType: typeName, + displayName: rawCheck.display_name, + enabledInDatabase: rawCheck.enabled, + typeNameInDatabase: rawCheck.type_name, + enabledTypesCount: enabledTypes.length, + enabledTypeNames: enabledTypeNames, + hint: 'Run schema sync to ensure all object types have a valid type_name, or use the Debug page to fix missing type names.', + }, + }); + return; + } + } + + res.status(400).json({ + success: false, + error: `Object type "${typeName}" is not enabled for syncing. Currently enabled types: ${enabledTypeNames.length > 0 ? enabledTypeNames.join(', ') : 'none'}. Please enable "${typeName}" in Schema Configuration settings (Settings → Schema Configuratie).`, + details: { + requestedType: typeName, + enabledInDatabase: rawCheck?.enabled, + typeNameInDatabase: rawCheck?.type_name, + enabledTypesCount: enabledTypes.length, + enabledTypeNames: enabledTypeNames, + hint: enabledTypeNames.length === 0 + ? 'No object types are currently enabled. Please enable at least one object type in Schema Configuration.' + : `You enabled: ${enabledTypeNames.join(', ')}. Please enable "${typeName}" if you want to sync it.`, + }, + }); + } else { + // Type not found by type_name - check by display_name (case-insensitive) + const db = services.schemaRepo.db; + const byDisplayName = await db.queryOne<{ enabled: boolean | number; type_name: string | null; display_name: string }>( + `SELECT enabled, type_name, display_name FROM object_types WHERE display_name ILIKE ? LIMIT 1`, + [`%${typeName}%`] + ); + + if (byDisplayName && (byDisplayName.enabled === true || byDisplayName.enabled === 1)) { + // Type is enabled but type_name might be missing or different + res.status(400).json({ + success: false, + error: `Found enabled type "${byDisplayName.display_name}" but it has ${byDisplayName.type_name ? `type_name="${byDisplayName.type_name}"` : 'missing type_name'}. ${!byDisplayName.type_name ? 'Please run schema sync to fix the type_name, or use the "Fix Missing Type Names" debug tool.' : `Please use the correct type_name: "${byDisplayName.type_name}"`}`, + details: { + requestedType: typeName, + foundDisplayName: byDisplayName.display_name, + foundTypeName: byDisplayName.type_name, + enabledInDatabase: byDisplayName.enabled, + hint: !byDisplayName.type_name + ? 'Run schema sync to ensure all object types have a valid type_name.' + : `Use type_name "${byDisplayName.type_name}" instead of "${typeName}"`, + }, + }); + return; + } + + res.status(400).json({ + success: false, + error: `Object type ${typeName} not found. Available enabled types: ${enabledTypes.map(t => t.typeName).join(', ') || 'none'}. Please run schema sync first.`, + }); + } + return; + } + + const result = await services.objectSyncService.syncObjectType( + type.schemaId, + type.objectTypeId, + type.typeName, + type.displayName + ); + + // Return success even if there are errors (errors are in result.errors array) + res.json({ + success: true, + ...result, + hasErrors: result.errors.length > 0, + }); + } catch (error) { + logger.error(`SyncController: Failed to sync object type ${req.params.typeName}`, error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } +} diff --git a/backend/src/api/routes/v2.ts b/backend/src/api/routes/v2.ts new file mode 100644 index 0000000..3765ef3 --- /dev/null +++ b/backend/src/api/routes/v2.ts @@ -0,0 +1,47 @@ +/** + * V2 API Routes - New refactored architecture + * + * Feature flag: USE_V2_API=true enables these routes + */ + +import { Router } from 'express'; +import { ObjectsController } from '../controllers/ObjectsController.js'; +import { SyncController } from '../controllers/SyncController.js'; +import { HealthController } from '../controllers/HealthController.js'; +import { DebugController } from '../controllers/DebugController.js'; +import { requireAuth, requirePermission } from '../../middleware/authorization.js'; + +const router = Router(); +const objectsController = new ObjectsController(); +const syncController = new SyncController(); +const healthController = new HealthController(); +const debugController = new DebugController(); + +// Health check - public endpoint (no auth required) +router.get('/health', (req, res) => healthController.health(req, res)); + +// All other routes require authentication +router.use(requireAuth); + +// Object routes +router.get('/objects/:type', requirePermission('search'), (req, res) => objectsController.getObjects(req, res)); +router.get('/objects/:type/:id', requirePermission('search'), (req, res) => objectsController.getObject(req, res)); +router.put('/objects/:type/:id', requirePermission('write'), (req, res) => objectsController.updateObject(req, res)); + +// Sync routes +router.post('/sync/schemas', requirePermission('admin'), (req, res) => syncController.syncSchemas(req, res)); +router.post('/sync/objects', requirePermission('admin'), (req, res) => syncController.syncAllObjects(req, res)); +router.post('/sync/objects/:typeName', requirePermission('admin'), (req, res) => syncController.syncObjectType(req, res)); + +// Debug routes (admin only) +// IMPORTANT: More specific routes must come BEFORE parameterized routes +router.post('/debug/query', requirePermission('admin'), (req, res) => debugController.executeQuery(req, res)); +router.get('/debug/objects', requirePermission('admin'), (req, res) => debugController.getObjectInfo(req, res)); +router.get('/debug/relations', requirePermission('admin'), (req, res) => debugController.getRelationInfo(req, res)); +router.get('/debug/all-object-types', requirePermission('admin'), (req, res) => debugController.getAllObjectTypes(req, res)); +router.post('/debug/fix-missing-type-names', requirePermission('admin'), (req, res) => debugController.fixMissingTypeNames(req, res)); +// Specific routes before parameterized routes +router.get('/debug/object-types/diagnose/:typeName', requirePermission('admin'), (req, res) => debugController.diagnoseObjectType(req, res)); +router.get('/debug/object-types/:typeName/stats', requirePermission('admin'), (req, res) => debugController.getObjectTypeStats(req, res)); + +export default router; diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 599567c..05f9f11 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -28,7 +28,6 @@ export type JiraAuthMethod = 'pat' | 'oauth'; interface Config { // Jira Assets jiraHost: string; - jiraSchemaId: string; // Jira Service Account Token (for read operations: sync, fetching data) jiraServiceAccountToken: string; @@ -90,7 +89,6 @@ function getJiraAuthMethod(): JiraAuthMethod { export const config: Config = { // Jira Assets jiraHost: getOptionalEnvVar('JIRA_HOST', 'https://jira.zuyderland.nl'), - jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'), // Jira Service Account Token (for read operations: sync, fetching data) jiraServiceAccountToken: getOptionalEnvVar('JIRA_SERVICE_ACCOUNT_TOKEN'), @@ -130,7 +128,6 @@ export function validateConfig(): void { if (config.jiraAuthMethod === 'pat') { // JIRA_PAT is configured in user profiles, not in ENV - warnings.push('JIRA_AUTH_METHOD=pat - users must configure PAT in their profile settings'); } else if (config.jiraAuthMethod === 'oauth') { if (!config.jiraOAuthClientId) { missingVars.push('JIRA_OAUTH_CLIENT_ID (required for OAuth authentication)'); @@ -143,16 +140,10 @@ export function validateConfig(): void { } } - // General required config - if (!config.jiraSchemaId) missingVars.push('JIRA_SCHEMA_ID'); - // Service account token warning (not required, but recommended for sync operations) if (!config.jiraServiceAccountToken) { warnings.push('JIRA_SERVICE_ACCOUNT_TOKEN not configured - sync and read operations may not work. Users can still use their personal PAT for reads as fallback.'); } - - // AI API keys are configured in user profiles, not in ENV - warnings.push('AI API keys must be configured in user profile settings'); if (warnings.length > 0) { warnings.forEach(w => console.warn(`Warning: ${w}`)); diff --git a/backend/src/domain/jiraAssetsPayload.ts b/backend/src/domain/jiraAssetsPayload.ts new file mode 100644 index 0000000..483358b --- /dev/null +++ b/backend/src/domain/jiraAssetsPayload.ts @@ -0,0 +1,121 @@ +// ========================== +// API Payload Types +// ========================== + +export interface AssetsPayload { + objectEntries: ObjectEntry[]; +} + +export interface ObjectEntry { + id: string | number; + objectKey: string; + label: string; + objectType: { + id: number; + name: string; + }; + created: string; + updated: string; + hasAvatar: boolean; + timestamp: number; + attributes?: ObjectAttribute[]; +} + +export interface ObjectAttribute { + id: number; + objectTypeAttributeId: number; + objectAttributeValues: ObjectAttributeValue[]; +} + +// ========================== +// Attribute Value Union +// ========================== + +export type ObjectAttributeValue = + | SimpleValue + | StatusValue + | ConfluenceValue + | UserValue + | ReferenceValue; + +export interface SimpleValue { + value: string | number | boolean; + searchValue: string; + referencedType: false; + displayValue: string; +} + +export interface StatusValue { + status: { id: number; name: string; category: number }; + searchValue: string; + referencedType: boolean; + displayValue: string; +} + +export interface ConfluenceValue { + confluencePage: { id: string; title: string; url: string }; + searchValue: string; + referencedType: boolean; + displayValue: string; +} + +export interface UserValue { + user: { + avatarUrl: string; + displayName: string; + name: string; + key: string; + renderedLink: string; + isDeleted: boolean; + }; + searchValue: string; + referencedType: boolean; + displayValue: string; +} + +export interface ReferenceValue { + referencedObject: ReferencedObject; + searchValue: string; + referencedType: true; + displayValue: string; +} + +export interface ReferencedObject { + id: string | number; + objectKey: string; + label: string; + name?: string; + archived?: boolean; + objectType: { + id: number; + name: string; + }; + created: string; + updated: string; + timestamp: number; + hasAvatar: boolean; + attributes?: ObjectAttribute[]; + _links?: { self: string }; +} + +// ========================== +// Type Guards (MANDATORY) +// ========================== + +export function isReferenceValue( + v: ObjectAttributeValue +): v is ReferenceValue { + return (v as ReferenceValue).referencedObject !== undefined; +} + +export function isSimpleValue( + v: ObjectAttributeValue +): v is SimpleValue { + return (v as SimpleValue).value !== undefined; +} + +export function hasAttributes( + obj: ObjectEntry | ReferencedObject +): obj is (ObjectEntry | ReferencedObject) & { attributes: ObjectAttribute[] } { + return Array.isArray((obj as any).attributes); +} diff --git a/backend/src/domain/syncPolicy.ts b/backend/src/domain/syncPolicy.ts new file mode 100644 index 0000000..3876681 --- /dev/null +++ b/backend/src/domain/syncPolicy.ts @@ -0,0 +1,38 @@ +/** + * Sync Policy - Determines how objects are handled during sync + */ + +export enum SyncPolicy { + /** + * Full sync: fetch all objects, cache all attributes + * Used for enabled object types in schema configuration + */ + ENABLED = 'enabled', + + /** + * Reference-only: cache minimal metadata for referenced objects + * Used for disabled object types that are referenced by enabled types + */ + REFERENCE_ONLY = 'reference_only', + + /** + * Skip: don't sync this object type at all + * Used for object types not in use + */ + SKIP = 'skip', +} + +/** + * Get sync policy for an object type + */ +export function getSyncPolicy( + typeName: string, + enabledTypes: Set +): SyncPolicy { + if (enabledTypes.has(typeName)) { + return SyncPolicy.ENABLED; + } + // We still need to cache referenced objects, even if their type is disabled + // This allows reference resolution without full sync + return SyncPolicy.REFERENCE_ONLY; +} diff --git a/backend/src/generated/db-schema-postgres.sql b/backend/src/generated/db-schema-postgres.sql index f436d1d..9a9965e 100644 --- a/backend/src/generated/db-schema-postgres.sql +++ b/backend/src/generated/db-schema-postgres.sql @@ -8,18 +8,12 @@ -- ============================================================================= -- 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 JSONB NOT NULL, - jira_updated_at TEXT, - jira_created_at TEXT, - cached_at TEXT NOT NULL -); +-- +-- NOTE: This schema is LEGACY and deprecated. +-- The current system uses the normalized schema defined in +-- backend/src/services/database/normalized-schema.ts +-- +-- This file is kept for reference and migration purposes only. -- Object relations (references between objects) CREATE TABLE IF NOT EXISTS object_relations ( @@ -43,12 +37,6 @@ CREATE TABLE IF NOT EXISTS sync_metadata ( -- 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_objects_data_gin ON cached_objects USING GIN (data); - 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); diff --git a/backend/src/generated/db-schema.sql b/backend/src/generated/db-schema.sql index aff3a98..c782481 100644 --- a/backend/src/generated/db-schema.sql +++ b/backend/src/generated/db-schema.sql @@ -7,18 +7,12 @@ -- ============================================================================= -- 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 -); +-- +-- NOTE: This schema is LEGACY and deprecated. +-- The current system uses the normalized schema defined in +-- backend/src/services/database/normalized-schema.ts +-- +-- This file is kept for reference and migration purposes only. -- Object relations (references between objects) CREATE TABLE IF NOT EXISTS object_relations ( @@ -42,11 +36,6 @@ CREATE TABLE IF NOT EXISTS sync_metadata ( -- 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); diff --git a/backend/src/generated/jira-schema.ts b/backend/src/generated/jira-schema.ts index 79c8c97..7c36f1f 100644 --- a/backend/src/generated/jira-schema.ts +++ b/backend/src/generated/jira-schema.ts @@ -1,8 +1,8 @@ // AUTO-GENERATED FILE - DO NOT EDIT MANUALLY -// Generated from Jira Assets Schema via REST API -// Generated at: 2026-01-09T02:12:50.973Z +// Generated from database schema +// Generated at: 2026-01-15T15:10:31.039Z // -// Re-generate with: npm run generate-schema +// Re-generate with: npm run generate-types // ============================================================================= // Schema Type Definitions @@ -20,6 +20,7 @@ export interface AttributeDefinition { referenceTypeId?: number; referenceTypeName?: string; description?: string; + position?: number; } export interface ObjectTypeDefinition { @@ -35,7 +36,7 @@ export interface ObjectTypeDefinition { // Schema Metadata // ============================================================================= -export const SCHEMA_GENERATED_AT = '2026-01-09T02:12:50.973Z'; +export const SCHEMA_GENERATED_AT = '2026-01-15T15:10:31.039Z'; export const SCHEMA_OBJECT_TYPE_COUNT = 35; export const SCHEMA_TOTAL_ATTRIBUTES = 365; @@ -49,56 +50,56 @@ export const OBJECT_TYPES: Record = { name: 'Application Component', typeName: 'ApplicationComponent', syncPriority: 1, - objectCount: 596, + objectCount: 601, 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: 355, name: 'Application Component Hosting Type', fieldName: 'applicationComponentHostingType', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'HostingType' }, + { jiraId: 358, name: 'Referenced Application Component', fieldName: 'referencedApplicationComponent', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, 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, referenceTypeName: 'ApplicationComponent', description: '*HIDDEN* zie CMDB-488' }, + { jiraId: 362, name: 'Monitoring', fieldName: 'monitoring', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationComponent', description: 'Cross reference naar Application Component' }, + { jiraId: 363, name: 'Supplier Product', fieldName: 'supplierProduct', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, 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, referenceTypeName: 'Supplier' }, + { jiraId: 365, name: 'Supplier Implementation', fieldName: 'supplierImplementation', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, 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, referenceTypeName: 'Supplier' }, + { jiraId: 368, name: 'Business Impact Analyse', fieldName: 'businessImpactAnalyse', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'BusinessImpactAnalyse' }, { 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: 394, name: 'CustomDevelopment', fieldName: 'customDevelopment', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Is er sprake van eigen programmatuur?' }, + { jiraId: 399, name: 'Business Importance', fieldName: 'businessImportance', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'BusinessImportance' }, + { jiraId: 416, name: 'Confluence Space', fieldName: 'confluenceSpace', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 425, name: 'SearchReference', fieldName: 'searchReference', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Additionele zoekwoorden t.b.v. search' }, + { jiraId: 569, name: 'Reference', fieldName: 'reference', type: 'text', isMultiple: false, isEditable: false, isRequired: false, isSystem: false, description: 'Niet aanpassen. GUID - Enterprise Architect' }, + { 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: 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: 2365, name: 'Business Owner', fieldName: 'businessOwner', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, 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, 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: 2416, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Application Lifecycle Management' }, { jiraId: 4497, name: 'Measures', fieldName: 'measures', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4538, name: 'Organisation', fieldName: 'organisation', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'Organisation' }, + { jiraId: 4540, name: 'Zenya ID', fieldName: 'zenyaID', type: 'integer', isMultiple: false, isEditable: false, isRequired: false, isSystem: false }, + { jiraId: 4613, name: 'Technical Application Management', fieldName: 'technicalApplicationManagement', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'group' }, + { jiraId: 4615, name: 'Zenya URL', fieldName: 'zenyaURL', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4666, name: 'ApplicationFunction', fieldName: 'applicationFunction', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationFunction' }, { 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 } + { jiraId: 4903, name: 'Application Management - Dynamics Factor', fieldName: 'applicationManagementDynamicsFactor', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationManagementDynamicsFactor' }, + { jiraId: 4904, name: 'Application Management - Complexity Factor', fieldName: 'applicationManagementComplexityFactor', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationManagementComplexityFactor' }, + { jiraId: 4905, name: 'Application Management - Number of Users', fieldName: 'applicationManagementNumberOfUsers', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationManagementNumberOfUsers' }, + { jiraId: 4906, name: 'ICT Governance Model', fieldName: 'ictGovernanceModel', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'IctGovernanceModel' }, + { jiraId: 4911, name: 'Application Management - Subteam', fieldName: 'applicationManagementSubteam', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationManagementSubteam' }, + { jiraId: 4918, name: 'Application Management - Application Type', fieldName: 'applicationManagementApplicationType', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationManagementApplicationType', description: 'De Type Classificatie bepaalt de aard en scope van het IT-object.' }, + { jiraId: 4927, name: 'Platform', fieldName: 'platform', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationComponent' }, + { jiraId: 4932, name: 'Application Management - Override FTE', fieldName: 'applicationManagementOverrideFTE', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4939, name: 'Application Management - Hosting', fieldName: 'applicationManagementHosting', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, 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, referenceTypeName: 'ApplicationManagementTam', description: 'Dit veld geeft aan wie het technisch applicatiebeheer uitvoert. Dit is de primaire factor voor het bepalen van het regiemodel.' } ], }, 'Flows': { @@ -106,23 +107,23 @@ export const OBJECT_TYPES: Record = { name: 'Flows', typeName: 'Flows', syncPriority: 1, - objectCount: 903, + objectCount: 907, 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: 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: 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: 567, name: 'Source', fieldName: 'source', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationComponent', description: 'Application Component - Source' }, + { jiraId: 568, name: 'Target', fieldName: 'target', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationComponent', description: 'Application Component - Target' }, { 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: 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: 4535, name: 'Search Reference Source', fieldName: 'searchReferenceSource', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4536, name: 'Search Reference Target', fieldName: 'searchReferenceTarget', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, { jiraId: 4801, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } ], }, @@ -131,27 +132,27 @@ export const OBJECT_TYPES: Record = { name: 'Server', typeName: 'Server', syncPriority: 1, - objectCount: 909, + objectCount: 919, 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: 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: 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: 450, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 455, name: 'VMOSType', fieldName: 'vMOSType', type: 'text', 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: 2406, name: 'Reboot group', fieldName: 'rebootGroup', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'Rebootgroups' }, + { 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, referenceTypeName: 'ApplicationComponent' }, + { jiraId: 4592, name: 'DBA', fieldName: 'dBA', type: 'boolean', 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: 4762, name: 'AzureSubscription', fieldName: 'azureSubscription', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'AzureSubscription' }, { 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 } @@ -162,44 +163,73 @@ export const OBJECT_TYPES: Record = { name: 'AzureSubscription', typeName: 'AzureSubscription', syncPriority: 2, - objectCount: 151, + objectCount: 152, 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: 4759, name: 'ApplicationComponent', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationComponent' }, + { jiraId: 4760, name: 'Server', fieldName: 'server', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'Server' }, + { jiraId: 4761, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, { jiraId: 4803, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } ], }, - 'PackageBuild': { - jiraTypeId: 424, - name: 'PackageBuild', - typeName: 'PackageBuild', + 'Certificate': { + jiraTypeId: 406, + name: 'Certificate', + typeName: 'Certificate', syncPriority: 2, - objectCount: 496, + objectCount: 508, 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 } + { 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: 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 }, + { jiraId: 4679, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Algemene omschrijving' }, + { jiraId: 4680, name: 'Domain', fieldName: 'domain', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'Domain', description: 'Verwijzing naar Domain' }, + { jiraId: 4681, name: 'Server', fieldName: 'server', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'Server', description: 'Verwijzing naar Server' }, + { jiraId: 4682, name: 'Issuing Authority', fieldName: 'issuingAuthority', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeName: 'Supplier', description: 'Het bedrijf of de organisatie/instantie die verantwoordelijk is voor de uitgifte van het certificaat' }, + { jiraId: 4683, name: 'Certificate Owner', fieldName: 'certificateOwner', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'Organization Unit', description: 'De (eind)verantwoordelijke (medewerker) van het certificaat binnen Zuyderland' }, + { 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, referenceTypeName: 'CertificateType', description: 'Type certificaat' }, + { jiraId: 4696, name: 'Classification Type', fieldName: 'classificationType', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeName: 'CertificateClassificationType', description: 'Classificatie type' }, + { 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: 4698, name: 'Requester', fieldName: 'requester', type: 'boolean', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'De aanvrager (medewerker) van het betreffende certificaat' }, + { jiraId: 4699, name: 'IT Operations Team', fieldName: 'itOperationsTeam', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, 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, 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, referenceTypeName: 'ApplicationComponent', description: 'Verwijzing naar Application Component' }, + { jiraId: 4702, name: 'Issuing Supplier', fieldName: 'issuingSupplier', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeName: 'Supplier', description: 'Het bedrijf of de organisatie/instantie waar het certificaat is besteld.' }, + { jiraId: 4719, name: 'Extra Installatie', fieldName: 'extraInstallatie', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationComponent' }, + { 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 } + ], + }, + '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: 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 }, + { 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: 2467, name: 'Application Component Monitoring', fieldName: 'applicationComponentMonitoring', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationComponent' }, + { jiraId: 2471, name: 'Domain Function', fieldName: 'domainFunction', type: 'reference', 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, referenceTypeName: 'Organization Unit' }, + { jiraId: 2474, name: 'Confluence', fieldName: 'confluence', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4543, name: 'Website availability', fieldName: 'websiteAvailability', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Intern/extern/local' }, + { jiraId: 4668, name: 'Domain Type', fieldName: 'domainType', 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, referenceTypeName: 'Supplier' }, + { jiraId: 4672, name: 'Registration date', fieldName: 'registrationDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4673, name: 'Application Component', fieldName: 'applicationComponent', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationComponent' }, + { jiraId: 4674, name: 'Mail enabled', fieldName: 'mailEnabled', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4685, name: 'Redirect URL', fieldName: 'redirectURL', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } ], }, 'Package': { @@ -213,46 +243,43 @@ export const OBJECT_TYPES: Record = { { 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: 4810, name: 'ApplicationComponent', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, 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: 4812, name: 'SupplierProduct', fieldName: 'supplierProduct', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'Supplier' }, + { jiraId: 4813, name: 'SupplierTechnical', fieldName: 'supplierTechnical', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, 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: 4836, name: 'Description', fieldName: 'description', type: 'text', 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', + 'PackageBuild': { + jiraTypeId: 424, + name: 'PackageBuild', + typeName: 'PackageBuild', syncPriority: 2, - objectCount: 508, + objectCount: 498, 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 } + { 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: 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: 4820, name: 'Software', fieldName: 'software', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeName: 'Software' }, + { jiraId: 4821, name: 'Version', fieldName: 'version', type: 'text', 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: 4835, name: 'Status', fieldName: 'status', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4837, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4839, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } ], }, 'PrivilegedUser': { @@ -264,10 +291,12 @@ export const OBJECT_TYPES: Record = { 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: 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: 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: 4623, name: 'Application Component', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, 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 }, @@ -278,38 +307,45 @@ export const OBJECT_TYPES: Record = { { 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: 4653, name: 'Self-Service Password Reset Enabled', fieldName: 'selfServicePasswordResetEnabled', type: 'boolean', 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', + 'Software': { + jiraTypeId: 421, + name: 'Software', + typeName: 'Software', syncPriority: 2, - objectCount: 796, + objectCount: 307, 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 } + { 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: 4810, name: 'ApplicationComponent', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, 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, referenceTypeName: 'Supplier' }, + { jiraId: 4813, name: 'SupplierTechnical', fieldName: 'supplierTechnical', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'Supplier' }, + { jiraId: 4836, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + 'SoftwarePatch': { + jiraTypeId: 423, + name: 'SoftwarePatch', + typeName: 'SoftwarePatch', + syncPriority: 2, + objectCount: 557, + 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: 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: 4820, name: 'Software', fieldName: 'software', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, 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: 4839, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } ], }, 'Supplier': { @@ -324,106 +360,37 @@ export const OBJECT_TYPES: Record = { { 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 } + { jiraId: 2412, name: 'Consultant 1 e-mail', fieldName: 'consultant1EMail', 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: 2414, name: 'Consultant 3 e-mail', fieldName: 'consultant3EMail', type: 'text', 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 } ], }, - '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', + 'ApplicationManagementSubteam': { + jiraTypeId: 435, + name: 'Application Management - Subteam', + typeName: 'ApplicationManagementSubteam', syncPriority: 5, - objectCount: 23, + objectCount: 19, 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 } + { jiraId: 4907, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { 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 }, + { jiraId: 4951, name: 'Application Management - Team', fieldName: 'applicationManagementTeam', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeName: 'ApplicationManagementTeam' } ], }, 'ApplicationManagementTeam': { @@ -440,19 +407,97 @@ export const OBJECT_TYPES: Record = { { 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', + 'Manual': { + jiraTypeId: 430, + name: 'Manual', + typeName: 'Manual', syncPriority: 5, - objectCount: 19, + objectCount: 23, 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 } + { 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: 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: 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: 4871, name: 'Target', fieldName: 'target', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, 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: 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 }, + { jiraId: 4516, name: 'Description', fieldName: 'description', 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: 4522, name: 'Obliged', fieldName: 'obliged', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4523, name: 'Optional', fieldName: 'optional', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + '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: 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 }, + { 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: 2397, name: 'Reboot day', fieldName: 'rebootDay', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + '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 } + ], + }, + '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 } + ], + }, + '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 } ], }, 'Assets': { @@ -490,66 +535,27 @@ export const OBJECT_TYPES: Record = { 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 } + { jiraId: 502, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4705, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } ], }, - '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', + 'ApplicationFunction': { + jiraTypeId: 403, + name: 'ApplicationFunction', + typeName: 'ApplicationFunction', syncPriority: 10, - objectCount: 3, + objectCount: 93, 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 } + { jiraId: 4646, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }, + { jiraId: 4647, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' }, + { 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: 4650, name: 'AppFuncGUID', fieldName: 'appFuncGUID', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4651, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4919, name: 'Application Management - Application Cluster', fieldName: 'applicationManagementApplicationCluster', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationManagementSubteam' }, + { jiraId: 4920, name: 'Keywords', fieldName: 'keywords', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4926, name: 'Application Function Category', fieldName: 'applicationFunctionCategory', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeName: 'ApplicationFunctionCategory' } ], }, 'ApplicationFunctionCategory': { @@ -566,101 +572,6 @@ export const OBJECT_TYPES: Record = { { 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', @@ -675,22 +586,6 @@ export const OBJECT_TYPES: Record = { { 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', @@ -703,8 +598,100 @@ export const OBJECT_TYPES: Record = { { 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 } + { jiraId: 4888, name: 'Factor', fieldName: 'factor', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4929, name: 'Description', fieldName: 'description', type: 'select', 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: 4901, name: 'Factor', fieldName: 'factor', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }, + { jiraId: 4928, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + '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: 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 }, + { 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: 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: 2411, name: 'BIA Check', fieldName: 'biaCheck', 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 } + ], + }, + '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: 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 }, + { jiraId: 4517, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], + }, + '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: 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 }, + { jiraId: 4703, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'https://jira.zuyderland.nl/browse/CMDB-430' } + ], + }, + '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 } + ], + }, + '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: 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 }, + { jiraId: 4520, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } ], }, 'IctGovernanceModel': { @@ -723,6 +710,20 @@ export const OBJECT_TYPES: Record = { { 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 } ], + }, + '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: 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 }, + { jiraId: 4511, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false } + ], } }; @@ -736,37 +737,37 @@ export const TYPE_ID_TO_NAME: Record = { 59: 'Flows', 48: 'Server', 418: 'AzureSubscription', - 424: 'PackageBuild', - 422: 'Package', 406: 'Certificate', - 401: 'PrivilegedUser', 252: 'Domain', - 37: 'Supplier', + 422: 'Package', + 424: 'PackageBuild', + 401: 'PrivilegedUser', 421: 'Software', 423: 'SoftwarePatch', + 37: 'Supplier', + 435: 'ApplicationManagementSubteam', + 440: 'ApplicationManagementTeam', 430: 'Manual', 391: 'Measures', 251: 'Rebootgroups', - 440: 'ApplicationManagementTeam', - 435: 'ApplicationManagementSubteam', + 438: 'ApplicationManagementHosting', + 433: 'ApplicationManagementNumberOfUsers', + 439: 'ApplicationManagementTam', 42: 'Assets', 392: 'CI', 57: 'Metadata', - 438: 'ApplicationManagementHosting', - 439: 'ApplicationManagementTam', - 433: 'ApplicationManagementNumberOfUsers', - 407: 'CertificateType', - 437: 'ApplicationFunctionCategory', 403: 'ApplicationFunction', - 408: 'CertificateClassificationType', + 437: 'ApplicationFunctionCategory', + 436: 'ApplicationManagementApplicationType', + 432: 'ApplicationManagementComplexityFactor', + 434: 'ApplicationManagementDynamicsFactor', 41: 'BusinessImpactAnalyse', 44: 'BusinessImportance', + 408: 'CertificateClassificationType', + 407: 'CertificateType', 39: 'HostingType', - 390: 'Organisation', - 436: 'ApplicationManagementApplicationType', - 434: 'ApplicationManagementDynamicsFactor', - 432: 'ApplicationManagementComplexityFactor', 431: 'IctGovernanceModel', + 390: 'Organisation', }; /** Map from TypeScript type name to Jira Type ID */ @@ -775,37 +776,37 @@ export const TYPE_NAME_TO_ID: Record = { 'Flows': 59, 'Server': 48, 'AzureSubscription': 418, - 'PackageBuild': 424, - 'Package': 422, 'Certificate': 406, - 'PrivilegedUser': 401, 'Domain': 252, - 'Supplier': 37, + 'Package': 422, + 'PackageBuild': 424, + 'PrivilegedUser': 401, 'Software': 421, 'SoftwarePatch': 423, + 'Supplier': 37, + 'ApplicationManagementSubteam': 435, + 'ApplicationManagementTeam': 440, 'Manual': 430, 'Measures': 391, 'Rebootgroups': 251, - 'ApplicationManagementTeam': 440, - 'ApplicationManagementSubteam': 435, + 'ApplicationManagementHosting': 438, + 'ApplicationManagementNumberOfUsers': 433, + 'ApplicationManagementTam': 439, 'Assets': 42, 'CI': 392, 'Metadata': 57, - 'ApplicationManagementHosting': 438, - 'ApplicationManagementTam': 439, - 'ApplicationManagementNumberOfUsers': 433, - 'CertificateType': 407, - 'ApplicationFunctionCategory': 437, 'ApplicationFunction': 403, - 'CertificateClassificationType': 408, + 'ApplicationFunctionCategory': 437, + 'ApplicationManagementApplicationType': 436, + 'ApplicationManagementComplexityFactor': 432, + 'ApplicationManagementDynamicsFactor': 434, 'BusinessImpactAnalyse': 41, 'BusinessImportance': 44, + 'CertificateClassificationType': 408, + 'CertificateType': 407, 'HostingType': 39, - 'Organisation': 390, - 'ApplicationManagementApplicationType': 436, - 'ApplicationManagementDynamicsFactor': 434, - 'ApplicationManagementComplexityFactor': 432, 'IctGovernanceModel': 431, + 'Organisation': 390, }; /** Map from Jira object type name to TypeScript type name */ @@ -814,37 +815,37 @@ export const JIRA_NAME_TO_TYPE: Record = { 'Flows': 'Flows', 'Server': 'Server', 'AzureSubscription': 'AzureSubscription', - 'PackageBuild': 'PackageBuild', - 'Package': 'Package', 'Certificate': 'Certificate', - 'Privileged User': 'PrivilegedUser', 'Domain': 'Domain', - 'Supplier': 'Supplier', + 'Package': 'Package', + 'PackageBuild': 'PackageBuild', + 'Privileged User': 'PrivilegedUser', 'Software': 'Software', 'SoftwarePatch': 'SoftwarePatch', + 'Supplier': 'Supplier', + 'Application Management - Subteam': 'ApplicationManagementSubteam', + 'Application Management - Team': 'ApplicationManagementTeam', 'Manual': 'Manual', 'Measures': 'Measures', 'Rebootgroups': 'Rebootgroups', - 'Application Management - Team': 'ApplicationManagementTeam', - 'Application Management - Subteam': 'ApplicationManagementSubteam', + 'Application Management - Hosting': 'ApplicationManagementHosting', + 'Application Management - Number of Users': 'ApplicationManagementNumberOfUsers', + 'Application Management - TAM': 'ApplicationManagementTam', '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', + 'ApplicationFunctionCategory': 'ApplicationFunctionCategory', + 'Application Management - Application Type': 'ApplicationManagementApplicationType', + 'Application Management - Complexity Factor': 'ApplicationManagementComplexityFactor', + 'Application Management - Dynamics Factor': 'ApplicationManagementDynamicsFactor', 'Business Impact Analyse': 'BusinessImpactAnalyse', 'Business Importance': 'BusinessImportance', + 'Certificate ClassificationType': 'CertificateClassificationType', + 'Certificate Type': 'CertificateType', 'Hosting Type': 'HostingType', - 'Organisation': 'Organisation', - 'Application Management - Application Type': 'ApplicationManagementApplicationType', - 'Application Management - Dynamics Factor': 'ApplicationManagementDynamicsFactor', - 'Application Management - Complexity Factor': 'ApplicationManagementComplexityFactor', 'ICT Governance Model': 'IctGovernanceModel', + 'Organisation': 'Organisation', }; // ============================================================================= diff --git a/backend/src/generated/jira-types.ts b/backend/src/generated/jira-types.ts index 16a527f..db2d8ee 100644 --- a/backend/src/generated/jira-types.ts +++ b/backend/src/generated/jira-types.ts @@ -1,8 +1,8 @@ // AUTO-GENERATED FILE - DO NOT EDIT MANUALLY -// Generated from Jira Assets Schema via REST API -// Generated at: 2026-01-09T02:12:50.973Z +// Generated from database schema +// Generated at: 2026-01-15T15:10:31.039Z // -// Re-generate with: npm run generate-schema +// Re-generate with: npm run generate-types // ============================================================================= // Base Types @@ -31,42 +31,37 @@ export interface BaseCMDBObject { // Object Type Interfaces // ============================================================================= -/** Application Component (Jira Type ID: 38, 596 objects) */ +/** Application Component (Jira Type ID: 38, 601 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: string | number | null; // Can be URL string (from Confluence link) or number (legacy) - 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[]; + customDevelopment: boolean | null; // Is er sprake van eigen programmatuur? + confluenceSpace: number | null; + searchReference: string | null; // Additionele zoekwoorden t.b.v. search + reference: string | null; // Niet aanpassen. GUID - Enterprise Architect + technischeArchitectuurTA: string | null; // Komt uit Enterprise Architect mee technicalApplicationManagementSecondary: boolean[]; medischeTechniek: boolean | null; // Is er een link met medische techniek - technischeArchitectuurTA: string | null; // Komt uit Enterprise Architect mee + functionalApplicationManagement: string | null; + status: string | null; // Application Lifecycle Management measures: string | null; + zenyaID: number | null; + zenyaURL: 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 @@ -74,39 +69,44 @@ export interface ApplicationComponent extends BaseCMDBObject { supplierTechnical: ObjectReference | null; // -> Supplier supplierImplementation: ObjectReference | null; // -> Supplier supplierConsultancy: ObjectReference | null; // -> Supplier + businessImpactAnalyse: ObjectReference | null; // -> BusinessImpactAnalyse + businessImportance: ObjectReference | null; // -> BusinessImportance businessOwner: ObjectReference | null; // -> Organization Unit systemOwner: ObjectReference | null; // -> User + organisation: ObjectReference | null; // -> Organisation technicalApplicationManagement: ObjectReference | null; // -> group + applicationFunction: ObjectReference[]; // -> ApplicationFunction 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 + ictGovernanceModel: ObjectReference | null; // -> IctGovernanceModel applicationManagementSubteam: ObjectReference | null; // -> ApplicationManagementSubteam + applicationManagementApplicationType: ObjectReference | null; // -> ApplicationManagementApplicationType + platform: ObjectReference | null; // -> ApplicationComponent + applicationManagementHosting: ObjectReference | null; // -> ApplicationManagementHosting + applicationManagementTAM: ObjectReference | null; // -> ApplicationManagementTam } -/** Flows (Jira Type ID: 59, 903 objects) */ +/** Flows (Jira Type ID: 59, 907 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; + created: string | null; + updated: string | null; type: string | null; protocol: string | null; details: string | null; broker: string | null; status: string | null; - created: string | null; - updated: string | null; + reference: string | null; + presentInImportEnterpriseArchitect: boolean | null; + searchReferenceSource: string | null; + searchReferenceTarget: string | null; importDate: string | null; // Reference attributes @@ -115,82 +115,112 @@ export interface Flows extends BaseCMDBObject { } -/** Server (Jira Type ID: 48, 909 objects) */ +/** Server (Jira Type ID: 48, 919 objects) */ export interface Server extends BaseCMDBObject { _objectType: 'Server'; // Scalar attributes key: string | null; name: string | null; // The name of the object - description: string | null; + created: string | null; + updated: string | null; status: string | null; - vMLocation: string | null; - vMOSType: string | null; memoryMB: number | null; + description: string | null; + vMOSType: string | null; cPUCores: number | null; nICCount: number | null; dataDisks: number | null; + vMLocation: string | null; + dBA: boolean | 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 + rebootGroup: ObjectReference | null; // -> Rebootgroups applicationComponent: ObjectReference | null; // -> ApplicationComponent azureSubscription: ObjectReference[]; // -> AzureSubscription - rebootGroup: ObjectReference | null; // -> Rebootgroups sourceStatus: ObjectReference | null; } -/** AzureSubscription (Jira Type ID: 418, 151 objects) */ +/** AzureSubscription (Jira Type ID: 418, 152 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; + status: string | null; importDate: string | null; // Reference attributes - server: ObjectReference[]; // -> Server applicationComponent: ObjectReference | null; // -> ApplicationComponent + server: ObjectReference[]; // -> Server } -/** PackageBuild (Jira Type ID: 424, 496 objects) */ -export interface PackageBuild extends BaseCMDBObject { - _objectType: 'PackageBuild'; +/** Certificate (Jira Type ID: 406, 508 objects) */ +export interface Certificate extends BaseCMDBObject { + _objectType: 'Certificate'; // Scalar attributes key: string | null; - name: string | null; // naamgeving: software-versie - version: string | null; + name: string | null; // Naam van het certificaat 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; + description: string | null; // Algemene omschrijving + status: string | null; // Status is Active, Closed, Unknown (date 1970-01-01) or Support Requested (ticket aangemaakt vanwege Expiry Date) + autorenew: boolean | null; // Indien een expiry date beschikbaar, dan op False + requester: boolean | null; // De aanvrager (medewerker) van het betreffende certificaat + expiryDate: string | null; // Als er geen Expiry Date bekend is, wordt de waarde ingesteld op 01-01-1970 + reminderMailbox: boolean[]; // Reference attributes - software: ObjectReference | null; // -> Software - status: ObjectReference | null; - environment: ObjectReference[]; + domain: ObjectReference[]; // -> Domain + server: ObjectReference[]; // -> Server + issuingAuthority: ObjectReference | null; // -> Supplier + certificateOwner: ObjectReference | null; // -> Organization Unit + type: ObjectReference | null; // -> CertificateType + classificationType: ObjectReference | null; // -> CertificateClassificationType + itOperationsTeam: ObjectReference | null; // -> group + applicationManagement: ObjectReference | null; // -> group + applicationComponent: ObjectReference[]; // -> ApplicationComponent + issuingSupplier: ObjectReference | null; // -> Supplier + extraInstallatie: ObjectReference[]; // -> ApplicationComponent + reminderInDays: ObjectReference | null; + +} + +/** 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 + created: string | null; + updated: string | null; + status: string | null; + description: string | null; + confluence: number | null; + registrationDate: string | null; + mailEnabled: boolean | null; + redirectURL: string | null; + + // Reference attributes + applicationComponentMonitoring: ObjectReference[]; // -> ApplicationComponent + domainFunction: ObjectReference | null; + businessOwner: ObjectReference | null; // -> Organization Unit + websiteAvailability: ObjectReference | null; + domainType: ObjectReference | null; + dnsManagement: ObjectReference | null; // -> Supplier + applicationComponent: ObjectReference[]; // -> ApplicationComponent } @@ -203,11 +233,11 @@ export interface Package extends BaseCMDBObject { 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; + description: string | null; importDate: string | null; // Reference attributes @@ -217,35 +247,32 @@ export interface Package extends BaseCMDBObject { } -/** Certificate (Jira Type ID: 406, 508 objects) */ -export interface Certificate extends BaseCMDBObject { - _objectType: 'Certificate'; +/** PackageBuild (Jira Type ID: 424, 498 objects) */ +export interface PackageBuild extends BaseCMDBObject { + _objectType: 'PackageBuild'; // 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 + name: string | null; // naamgeving: software-versie created: string | null; updated: string | null; + version: 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; + description: 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 + software: ObjectReference | null; // -> Software + environment: ObjectReference[]; + status: ObjectReference | null; } @@ -256,6 +283,8 @@ export interface PrivilegedUser extends BaseCMDBObject { // Scalar attributes key: string | null; name: string | null; // The name of the object + created: string | null; + updated: string | null; uPN: string | null; // User Principal Name description: string | null; // Description topdeskTicket: string | null; @@ -269,11 +298,9 @@ export interface PrivilegedUser extends BaseCMDBObject { passwordNeverExpires: boolean | null; mfaRegistered: boolean | null; selfServicePasswordResetUsed: boolean | null; - selfServicePasswordResetEnabled: boolean | null; lastLogon: string | null; - created: string | null; - updated: string | null; sID: string | null; + selfServicePasswordResetEnabled: boolean | null; importDate: string | null; // Reference attributes @@ -281,30 +308,40 @@ export interface PrivilegedUser extends BaseCMDBObject { } -/** Domain (Jira Type ID: 252, 796 objects) */ -export interface Domain extends BaseCMDBObject { - _objectType: 'Domain'; +/** 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 - status: string | null; - description: string | null; - registrationDate: string | null; - redirectURL: string | null; - mailEnabled: boolean | null; - confluence: number | null; created: string | null; updated: string | null; + status: string | null; + description: 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 + applicationComponent: ObjectReference | null; // -> ApplicationComponent + supplierProduct: ObjectReference | null; // -> Supplier + supplierTechnical: ObjectReference | null; // -> Supplier + +} + +/** SoftwarePatch (Jira Type ID: 423, 557 objects) */ +export interface SoftwarePatch extends BaseCMDBObject { + _objectType: 'SoftwarePatch'; + + // Scalar attributes + key: string | null; + name: string | null; // naamgeving: software-versie + created: string | null; + updated: string | null; + version: string | null; + description: string | null; + + // Reference attributes + software: ObjectReference | null; // -> Software + status: ObjectReference | null; } @@ -318,28 +355,28 @@ export interface Supplier extends BaseCMDBObject { 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; + consultant1EMail: string | null; + consultant2EMail: string | null; consultant3EMail: string | null; + street: string | null; + houseNumber: string | null; + city: string | null; + postalCode: string | null; + countryCode: string | null; } -/** Software (Jira Type ID: 421, 307 objects) */ -export interface Software extends BaseCMDBObject { - _objectType: 'Software'; +/** Application Management - Subteam (Jira Type ID: 435, 19 objects) */ +export interface ApplicationManagementSubteam extends BaseCMDBObject { + _objectType: 'ApplicationManagementSubteam'; // Scalar attributes key: string | null; @@ -347,80 +384,9 @@ export interface Software extends BaseCMDBObject { 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; + applicationManagementTeam: ObjectReference | null; // -> ApplicationManagementTeam } @@ -439,9 +405,59 @@ export interface ApplicationManagementTeam extends BaseCMDBObject { } -/** Application Management - Subteam (Jira Type ID: 435, 19 objects) */ -export interface ApplicationManagementSubteam extends BaseCMDBObject { - _objectType: 'ApplicationManagementSubteam'; +/** 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 + created: string | null; + updated: string | null; + link: number[]; // Confluence manual page + + // 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 + created: string | null; + updated: string | null; + description: string | null; + obliged: 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 + created: string | null; + updated: string | null; + rebootTime: string | null; + installInformation: string | null; + rebootDay: string | null; + +} + +/** Application Management - Hosting (Jira Type ID: 438, 4 objects) */ +export interface ApplicationManagementHosting extends BaseCMDBObject { + _objectType: 'ApplicationManagementHosting'; // Scalar attributes key: string | null; @@ -450,8 +466,33 @@ export interface ApplicationManagementSubteam extends BaseCMDBObject { updated: string | null; description: string | null; - // Reference attributes - applicationManagementTeam: ObjectReference | null; // -> ApplicationManagementTeam +} + +/** 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; + +} + +/** 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; } @@ -483,19 +524,6 @@ export interface CI extends BaseCMDBObject { 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 @@ -505,43 +533,22 @@ export interface ApplicationManagementHosting extends BaseCMDBObject { } -/** Application Management - TAM (Jira Type ID: 439, 4 objects) */ -export interface ApplicationManagementTam extends BaseCMDBObject { - _objectType: 'ApplicationManagementTam'; +/** ApplicationFunction (Jira Type ID: 403, 93 objects) */ +export interface ApplicationFunction extends BaseCMDBObject { + _objectType: 'ApplicationFunction'; // Scalar attributes key: string | null; name: string | null; // The name of the object created: string | null; updated: string | null; + appFuncGUID: string | null; description: string | null; + keywords: 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; + // Reference attributes + applicationManagementApplicationCluster: ObjectReference | null; // -> ApplicationManagementSubteam + applicationFunctionCategory: ObjectReference | null; // -> ApplicationFunctionCategory } @@ -558,97 +565,6 @@ export interface ApplicationFunctionCategory extends BaseCMDBObject { } -/** 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'; @@ -662,6 +578,21 @@ export interface ApplicationManagementApplicationType extends BaseCMDBObject { } +/** 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; + factor: number | null; + description: string | null; + +} + /** Application Management - Dynamics Factor (Jira Type ID: 434, 4 objects) */ export interface ApplicationManagementDynamicsFactor extends BaseCMDBObject { _objectType: 'ApplicationManagementDynamicsFactor'; @@ -672,23 +603,79 @@ export interface ApplicationManagementDynamicsFactor extends BaseCMDBObject { created: string | null; updated: string | null; summary: string | null; - description: string | null; factor: number | null; + description: string | null; } -/** Application Management - Complexity Factor (Jira Type ID: 432, 4 objects) */ -export interface ApplicationManagementComplexityFactor extends BaseCMDBObject { - _objectType: 'ApplicationManagementComplexityFactor'; +/** 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 + created: string | null; + updated: string | null; + serviceWindow: string | null; + availabilty: string | null; + recoveryTimeObjectiveRTO: string | null; + recoveryPointObjectiveRPO: string | null; + environments: string | null; + biaCheck: string | null; + description: string | null; + indicators: 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 + created: string | null; + updated: string | null; + description: string | null; + +} + +/** 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 + created: string | null; + updated: string | null; + description: string | null; // https://jira.zuyderland.nl/browse/CMDB-430 + +} + +/** 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; + +} + +/** 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 created: string | null; updated: string | null; - summary: string | null; description: string | null; - factor: number | null; } @@ -708,6 +695,19 @@ export interface IctGovernanceModel extends BaseCMDBObject { } +/** 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 + created: string | null; + updated: string | null; + description: string | null; + +} + // ============================================================================= // Union Types // ============================================================================= @@ -718,37 +718,37 @@ export type CMDBObject = | Flows | Server | AzureSubscription - | PackageBuild - | Package | Certificate - | PrivilegedUser | Domain - | Supplier + | Package + | PackageBuild + | PrivilegedUser | Software | SoftwarePatch + | Supplier + | ApplicationManagementSubteam + | ApplicationManagementTeam | Manual | Measures | Rebootgroups - | ApplicationManagementTeam - | ApplicationManagementSubteam + | ApplicationManagementHosting + | ApplicationManagementNumberOfUsers + | ApplicationManagementTam | Assets | CI | Metadata - | ApplicationManagementHosting - | ApplicationManagementTam - | ApplicationManagementNumberOfUsers - | CertificateType - | ApplicationFunctionCategory | ApplicationFunction - | CertificateClassificationType + | ApplicationFunctionCategory + | ApplicationManagementApplicationType + | ApplicationManagementComplexityFactor + | ApplicationManagementDynamicsFactor | BusinessImpactAnalyse | BusinessImportance + | CertificateClassificationType + | CertificateType | HostingType - | Organisation - | ApplicationManagementApplicationType - | ApplicationManagementDynamicsFactor - | ApplicationManagementComplexityFactor - | IctGovernanceModel; + | IctGovernanceModel + | Organisation; /** All valid object type names */ export type CMDBObjectTypeName = @@ -756,37 +756,37 @@ export type CMDBObjectTypeName = | 'Flows' | 'Server' | 'AzureSubscription' - | 'PackageBuild' - | 'Package' | 'Certificate' - | 'PrivilegedUser' | 'Domain' - | 'Supplier' + | 'Package' + | 'PackageBuild' + | 'PrivilegedUser' | 'Software' | 'SoftwarePatch' + | 'Supplier' + | 'ApplicationManagementSubteam' + | 'ApplicationManagementTeam' | 'Manual' | 'Measures' | 'Rebootgroups' - | 'ApplicationManagementTeam' - | 'ApplicationManagementSubteam' + | 'ApplicationManagementHosting' + | 'ApplicationManagementNumberOfUsers' + | 'ApplicationManagementTam' | 'Assets' | 'CI' | 'Metadata' - | 'ApplicationManagementHosting' - | 'ApplicationManagementTam' - | 'ApplicationManagementNumberOfUsers' - | 'CertificateType' - | 'ApplicationFunctionCategory' | 'ApplicationFunction' - | 'CertificateClassificationType' + | 'ApplicationFunctionCategory' + | 'ApplicationManagementApplicationType' + | 'ApplicationManagementComplexityFactor' + | 'ApplicationManagementDynamicsFactor' | 'BusinessImpactAnalyse' | 'BusinessImportance' + | 'CertificateClassificationType' + | 'CertificateType' | 'HostingType' - | 'Organisation' - | 'ApplicationManagementApplicationType' - | 'ApplicationManagementDynamicsFactor' - | 'ApplicationManagementComplexityFactor' - | 'IctGovernanceModel'; + | 'IctGovernanceModel' + | 'Organisation'; // ============================================================================= // Type Guards @@ -808,28 +808,24 @@ 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 isPackage(obj: CMDBObject): obj is Package { + return obj._objectType === 'Package'; +} + +export function isPackageBuild(obj: CMDBObject): obj is PackageBuild { + return obj._objectType === 'PackageBuild'; +} + +export function isPrivilegedUser(obj: CMDBObject): obj is PrivilegedUser { + return obj._objectType === 'PrivilegedUser'; } export function isSoftware(obj: CMDBObject): obj is Software { @@ -840,6 +836,18 @@ export function isSoftwarePatch(obj: CMDBObject): obj is SoftwarePatch { return obj._objectType === 'SoftwarePatch'; } +export function isSupplier(obj: CMDBObject): obj is Supplier { + return obj._objectType === 'Supplier'; +} + +export function isApplicationManagementSubteam(obj: CMDBObject): obj is ApplicationManagementSubteam { + return obj._objectType === 'ApplicationManagementSubteam'; +} + +export function isApplicationManagementTeam(obj: CMDBObject): obj is ApplicationManagementTeam { + return obj._objectType === 'ApplicationManagementTeam'; +} + export function isManual(obj: CMDBObject): obj is Manual { return obj._objectType === 'Manual'; } @@ -852,12 +860,16 @@ 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 isApplicationManagementHosting(obj: CMDBObject): obj is ApplicationManagementHosting { + return obj._objectType === 'ApplicationManagementHosting'; } -export function isApplicationManagementSubteam(obj: CMDBObject): obj is ApplicationManagementSubteam { - return obj._objectType === 'ApplicationManagementSubteam'; +export function isApplicationManagementNumberOfUsers(obj: CMDBObject): obj is ApplicationManagementNumberOfUsers { + return obj._objectType === 'ApplicationManagementNumberOfUsers'; +} + +export function isApplicationManagementTam(obj: CMDBObject): obj is ApplicationManagementTam { + return obj._objectType === 'ApplicationManagementTam'; } export function isAssets(obj: CMDBObject): obj is Assets { @@ -872,32 +884,24 @@ 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 isApplicationFunction(obj: CMDBObject): obj is ApplicationFunction { + return obj._objectType === 'ApplicationFunction'; } 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 isApplicationManagementApplicationType(obj: CMDBObject): obj is ApplicationManagementApplicationType { + return obj._objectType === 'ApplicationManagementApplicationType'; } -export function isCertificateClassificationType(obj: CMDBObject): obj is CertificateClassificationType { - return obj._objectType === 'CertificateClassificationType'; +export function isApplicationManagementComplexityFactor(obj: CMDBObject): obj is ApplicationManagementComplexityFactor { + return obj._objectType === 'ApplicationManagementComplexityFactor'; +} + +export function isApplicationManagementDynamicsFactor(obj: CMDBObject): obj is ApplicationManagementDynamicsFactor { + return obj._objectType === 'ApplicationManagementDynamicsFactor'; } export function isBusinessImpactAnalyse(obj: CMDBObject): obj is BusinessImpactAnalyse { @@ -908,26 +912,22 @@ export function isBusinessImportance(obj: CMDBObject): obj is BusinessImportance return obj._objectType === 'BusinessImportance'; } +export function isCertificateClassificationType(obj: CMDBObject): obj is CertificateClassificationType { + return obj._objectType === 'CertificateClassificationType'; +} + +export function isCertificateType(obj: CMDBObject): obj is CertificateType { + return obj._objectType === 'CertificateType'; +} + 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'; } + +export function isOrganisation(obj: CMDBObject): obj is Organisation { + return obj._objectType === 'Organisation'; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index a960401..626ef1b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,7 +6,6 @@ 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'; @@ -22,6 +21,8 @@ import searchRouter from './routes/search.js'; import cacheRouter from './routes/cache.js'; import objectsRouter from './routes/objects.js'; import schemaRouter from './routes/schema.js'; +import dataValidationRouter from './routes/dataValidation.js'; +import schemaConfigurationRouter from './routes/schemaConfiguration.js'; import { runMigrations } from './services/database/migrations.js'; // Validate configuration @@ -63,8 +64,10 @@ app.use(authMiddleware); // Set user token and settings on services for each request app.use(async (req, res, next) => { // Set user's OAuth token if available (for OAuth sessions) + let userToken: string | null = null; + if (req.accessToken) { - cmdbService.setUserToken(req.accessToken); + userToken = req.accessToken; } // Set user's Jira PAT and AI keys if user is authenticated and has local account @@ -75,15 +78,12 @@ app.use(async (req, res, next) => { if (settings?.jira_pat) { // Use user's Jira PAT from profile settings (preferred for writes) - cmdbService.setUserToken(settings.jira_pat); + userToken = settings.jira_pat; } else if (config.jiraServiceAccountToken) { // Fallback to service account token if user doesn't have PAT configured // This allows writes to work when JIRA_SERVICE_ACCOUNT_TOKEN is set in .env - cmdbService.setUserToken(config.jiraServiceAccountToken); + userToken = config.jiraServiceAccountToken; logger.debug('Using service account token as fallback (user PAT not configured)'); - } else { - // No token available - clear token - cmdbService.setUserToken(null); } // Store user settings in request for services to access @@ -92,18 +92,35 @@ app.use(async (req, res, next) => { // If user settings can't be loaded, try service account token as fallback logger.debug('Failed to load user settings:', error); if (config.jiraServiceAccountToken) { - cmdbService.setUserToken(config.jiraServiceAccountToken); + userToken = config.jiraServiceAccountToken; logger.debug('Using service account token as fallback (user settings load failed)'); - } else { - cmdbService.setUserToken(null); } } + } + + // Set token on old services (for backward compatibility) + if (userToken) { + cmdbService.setUserToken(userToken); } else { - // No user authenticated - clear token cmdbService.setUserToken(null); } + + // Set token on new V2 infrastructure client (if feature flag enabled) + if (process.env.USE_V2_API === 'true') { + try { + const { jiraAssetsClient } = await import('./infrastructure/jira/JiraAssetsClient.js'); + jiraAssetsClient.setRequestToken(userToken); + + // Clear token after response + res.on('finish', () => { + jiraAssetsClient.clearRequestToken(); + }); + } catch (error) { + // V2 API not loaded - ignore + } + } - // Clear token after response is sent + // Clear token after response is sent (for old services) res.on('finish', () => { cmdbService.clearUserToken(); }); @@ -119,8 +136,8 @@ app.get('/health', async (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), - dataSource: dataService.isUsingJiraAssets() ? 'jira-assets-cached' : 'mock-data', - jiraConnected: dataService.isUsingJiraAssets() ? jiraConnected : null, + dataSource: 'jira-assets-cached', // Always uses Jira Assets (mock data removed) + jiraConnected: jiraConnected, aiConfigured: true, // AI is configured per-user in profile settings cache: { isWarm: cacheStatus.isWarm, @@ -152,6 +169,38 @@ app.use('/api/search', searchRouter); app.use('/api/cache', cacheRouter); app.use('/api/objects', objectsRouter); app.use('/api/schema', schemaRouter); +app.use('/api/data-validation', dataValidationRouter); +app.use('/api/schema-configuration', schemaConfigurationRouter); + +// V2 API routes (new refactored architecture) - Feature flag: USE_V2_API +const useV2Api = process.env.USE_V2_API === 'true'; +const useV2ApiEnv = process.env.USE_V2_API || 'not set'; +logger.info(`V2 API feature flag: USE_V2_API=${useV2ApiEnv} (enabled: ${useV2Api})`); + +if (useV2Api) { + try { + logger.debug('Loading V2 API routes from ./api/routes/v2.js...'); + const v2Router = (await import('./api/routes/v2.js')).default; + if (!v2Router) { + logger.error('❌ V2 API router is undefined - route file did not export default router'); + } else { + app.use('/api/v2', v2Router); + logger.info('✅ V2 API routes enabled and mounted at /api/v2'); + logger.debug('V2 API router type:', typeof v2Router, 'is function:', typeof v2Router === 'function'); + } + } catch (error) { + logger.error('❌ Failed to load V2 API routes', error); + if (error instanceof Error) { + logger.error('Error details:', { + message: error.message, + stack: error.stack, + name: error.name, + }); + } + } +} else { + logger.info(`ℹ️ V2 API routes disabled (USE_V2_API=${useV2ApiEnv}, set USE_V2_API=true to enable)`); +} // Error handling app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -164,7 +213,20 @@ app.use((err: Error, req: express.Request, res: express.Response, next: express. // 404 handler app.use((req, res) => { - res.status(404).json({ error: 'Not found' }); + // Provide helpful error messages for V2 API routes + if (req.path.startsWith('/api/v2/')) { + const useV2Api = process.env.USE_V2_API === 'true'; + if (!useV2Api) { + res.status(404).json({ + error: 'V2 API routes are not enabled', + message: 'Please set USE_V2_API=true in environment variables and restart the server to use V2 API endpoints.', + path: req.path, + }); + return; + } + } + + res.status(404).json({ error: 'Not found', path: req.path }); }); // Start server @@ -173,26 +235,51 @@ app.listen(PORT, async () => { logger.info(`Server running on http://localhost:${PORT}`); logger.info(`Environment: ${config.nodeEnv}`); logger.info(`AI Classification: Configured per-user in profile settings`); - logger.info(`Jira Assets: ${config.jiraSchemaId ? 'Schema configured - users configure PAT in profile' : 'Schema not configured'}`); - // Run database migrations + // Log V2 API feature flag status + const useV2ApiEnv = process.env.USE_V2_API || 'not set'; + const useV2ApiEnabled = process.env.USE_V2_API === 'true'; + logger.info(`V2 API Feature Flag: USE_V2_API=${useV2ApiEnv} (${useV2ApiEnabled ? '✅ ENABLED' : '❌ DISABLED'})`); + // Check if schemas exist in database + // Note: Schemas table may not exist yet if schema hasn't been initialized + let hasSchemas = false; try { - await runMigrations(); - logger.info('Database migrations completed'); + const { normalizedCacheStore } = await import('./services/normalizedCacheStore.js'); + const db = (normalizedCacheStore as any).db; + if (db) { + await db.ensureInitialized?.(); + try { + const schemaRow = await db.queryOne<{ count: number }>( + `SELECT COUNT(*) as count FROM schemas` + ); + hasSchemas = (schemaRow?.count || 0) > 0; + } catch (tableError: any) { + // If schemas table doesn't exist yet, that's okay - schema hasn't been initialized + if (tableError?.message?.includes('does not exist') || + tableError?.message?.includes('relation') || + tableError?.code === '42P01') { // PostgreSQL: undefined table + logger.debug('Schemas table does not exist yet (will be created by migrations)'); + hasSchemas = false; + } else { + throw tableError; // Re-throw other errors + } + } + } } catch (error) { - logger.error('Failed to run database migrations', error); + logger.debug('Failed to check if schemas exist in database (table may not exist yet)', error); } - // Initialize sync engine if Jira schema is configured - // Note: Sync engine will only sync when users with configured Jira PATs make requests - // This prevents unauthorized Jira API calls - if (config.jiraSchemaId) { - try { - await syncEngine.initialize(); - logger.info('Sync Engine: Initialized (sync on-demand per user request)'); - } catch (error) { - logger.error('Failed to initialize sync engine', error); - } + logger.info(`Jira Assets: ${hasSchemas ? 'Schemas configured in database - users configure PAT in profile' : 'No schemas configured - use Schema Configuration page to discover schemas'}`); + logger.info('Sync: All syncs must be triggered manually from the GUI (no auto-start)'); + logger.info('Data: All data comes from Jira Assets API (mock data removed)'); + + // Run database migrations FIRST to create schemas table before other services try to use it + try { + logger.info('Running database migrations...'); + await runMigrations(); + logger.info('✅ Database migrations completed'); + } catch (error) { + logger.error('❌ Failed to run database migrations', error); } }); @@ -200,8 +287,7 @@ app.listen(PORT, async () => { const shutdown = () => { logger.info('Shutdown signal received: stopping services...'); - // Stop sync engine - syncEngine.stop(); + // Note: No sync engine to stop - syncs are only triggered from GUI logger.info('Services stopped, exiting'); process.exit(0); diff --git a/backend/src/infrastructure/jira/JiraAssetsClient.ts b/backend/src/infrastructure/jira/JiraAssetsClient.ts new file mode 100644 index 0000000..11095f6 --- /dev/null +++ b/backend/src/infrastructure/jira/JiraAssetsClient.ts @@ -0,0 +1,330 @@ +/** + * JiraAssetsClient - Pure HTTP API client + * + * NO business logic, NO parsing, NO caching. + * Only HTTP requests to Jira Assets API. + */ + +import { config } from '../../config/env.js'; +import { logger } from '../../services/logger.js'; +import type { AssetsPayload, ObjectEntry } from '../../domain/jiraAssetsPayload.js'; + +export interface JiraUpdatePayload { + objectTypeId?: number; + attributes: Array<{ + objectTypeAttributeId: number; + objectAttributeValues: Array<{ value?: string }>; + }>; +} + +export class JiraAssetsClient { + private baseUrl: string; + private serviceAccountToken: string | null = null; + private requestToken: string | null = null; + + constructor() { + this.baseUrl = `${config.jiraHost}/rest/insight/1.0`; + this.serviceAccountToken = config.jiraServiceAccountToken || null; + } + + setRequestToken(token: string | null): void { + this.requestToken = token; + } + + clearRequestToken(): void { + this.requestToken = null; + } + + hasToken(): boolean { + return !!(this.serviceAccountToken || this.requestToken); + } + + hasUserToken(): boolean { + return !!this.requestToken; + } + + private getHeaders(forWrite: boolean = false): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + if (forWrite) { + if (!this.requestToken) { + throw new Error('Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.'); + } + headers['Authorization'] = `Bearer ${this.requestToken}`; + } else { + const token = this.serviceAccountToken || this.requestToken; + if (!token) { + throw new Error('Jira token not configured. Please configure JIRA_SERVICE_ACCOUNT_TOKEN in .env or a Personal Access Token in your user settings.'); + } + headers['Authorization'] = `Bearer ${token}`; + } + + return headers; + } + + /** + * Get a single object by ID + */ + async getObject(objectId: string): Promise { + try { + const url = `/object/${objectId}?includeAttributes=true&includeAttributesDeep=2`; + const response = await fetch(`${this.baseUrl}${url}`, { + headers: this.getHeaders(false), + }); + + if (!response.ok) { + if (response.status === 404) { + return null; + } + const text = await response.text(); + throw new Error(`Jira API error ${response.status}: ${text}`); + } + + return await response.json() as ObjectEntry; + } catch (error) { + logger.error(`JiraAssetsClient: Failed to get object ${objectId}`, error); + throw error; + } + } + + /** + * Search objects using IQL/AQL + */ + async searchObjects( + iql: string, + schemaId: string, + options: { + page?: number; + pageSize?: number; + } = {} + ): Promise<{ objectEntries: ObjectEntry[]; totalCount: number; hasMore: boolean }> { + // Validate schemaId is provided and not empty + if (!schemaId || schemaId.trim() === '') { + throw new Error('Schema ID is required and cannot be empty. This usually means the object type is not properly associated with a schema. Please run schema sync first.'); + } + + const { page = 1, pageSize = 50 } = options; + + // Detect API type (Data Center vs Cloud) based on host + const isDataCenter = !config.jiraHost.includes('atlassian.net'); + + let response: { objectEntries: ObjectEntry[]; totalCount?: number; totalFilterCount?: number }; + + if (isDataCenter) { + // Data Center: Try AQL first, fallback to IQL + try { + const params = new URLSearchParams({ + qlQuery: iql, + page: page.toString(), + resultPerPage: pageSize.toString(), + includeAttributes: 'true', + includeAttributesDeep: '2', + objectSchemaId: schemaId, + }); + const url = `${this.baseUrl}/aql/objects?${params.toString()}`; + const httpResponse = await fetch(url, { + headers: this.getHeaders(false), + }); + if (!httpResponse.ok) { + const errorText = await httpResponse.text(); + const errorMessage = errorText || `AQL failed: ${httpResponse.status}`; + logger.warn(`JiraAssetsClient: AQL query failed (${httpResponse.status}): ${errorMessage}. Query: ${iql}`); + throw new Error(errorMessage); + } + response = await httpResponse.json() as { objectEntries: ObjectEntry[]; totalCount?: number; totalFilterCount?: number }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn(`JiraAssetsClient: AQL endpoint failed, falling back to IQL. Error: ${errorMessage}`, error); + const params = new URLSearchParams({ + iql, + page: page.toString(), + resultPerPage: pageSize.toString(), + includeAttributes: 'true', + includeAttributesDeep: '2', + objectSchemaId: schemaId, + }); + const url = `${this.baseUrl}/iql/objects?${params.toString()}`; + const httpResponse = await fetch(url, { + headers: this.getHeaders(false), + }); + if (!httpResponse.ok) { + const text = await httpResponse.text(); + throw new Error(`Jira API error ${httpResponse.status}: ${text}`); + } + response = await httpResponse.json() as { objectEntries: ObjectEntry[]; totalCount?: number; totalFilterCount?: number }; + } + } else { + // Jira Cloud: POST to AQL endpoint + const url = `${this.baseUrl}/aql/objects`; + const requestBody = { + qlQuery: iql, + page, + resultPerPage: pageSize, + includeAttributes: true, + includeAttributesDeep: 2, + objectSchemaId: schemaId, + }; + const httpResponse = await fetch(url, { + method: 'POST', + headers: this.getHeaders(false), + body: JSON.stringify(requestBody), + }); + if (!httpResponse.ok) { + const text = await httpResponse.text(); + const errorMessage = text || `Jira API error ${httpResponse.status}`; + logger.warn(`JiraAssetsClient: AQL query failed (${httpResponse.status}): ${errorMessage}. Query: ${iql}`); + throw new Error(errorMessage); + } + response = await httpResponse.json() as { objectEntries: ObjectEntry[]; totalCount?: number; totalFilterCount?: number }; + } + + const totalCount = response.totalFilterCount || response.totalCount || 0; + const hasMore = response.objectEntries.length === pageSize && page * pageSize < totalCount; + + return { + objectEntries: response.objectEntries || [], + totalCount, + hasMore, + }; + } + + /** + * Update an object + */ + async updateObject(objectId: string, payload: JiraUpdatePayload): Promise { + if (!this.hasUserToken()) { + throw new Error('Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.'); + } + + const url = `${this.baseUrl}/object/${objectId}`; + const response = await fetch(url, { + method: 'PUT', + headers: this.getHeaders(true), + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Jira API error ${response.status}: ${text}`); + } + } + + /** + * Get all schemas + */ + async getSchemas(): Promise> { + const url = `${this.baseUrl}/objectschema/list`; + const response = await fetch(url, { + headers: this.getHeaders(false), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Jira API error ${response.status}: ${text}`); + } + + return await response.json() as Array<{ id: string; name: string; description?: string }>; + } + + /** + * Get object types for a schema + */ + async getObjectTypes(schemaId: string): Promise> { + // Try flat endpoint first + let url = `${this.baseUrl}/objectschema/${schemaId}/objecttypes/flat`; + let response = await fetch(url, { + headers: this.getHeaders(false), + }); + + if (!response.ok) { + // Fallback to regular endpoint + url = `${this.baseUrl}/objectschema/${schemaId}/objecttypes`; + response = await fetch(url, { + headers: this.getHeaders(false), + }); + } + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Jira API error ${response.status}: ${text}`); + } + + const result = await response.json() as unknown; + if (Array.isArray(result)) { + return result as Array<{ + id: number; + name: string; + description?: string; + objectCount?: number; + parentObjectTypeId?: number; + abstractObjectType?: boolean; + }>; + } else if (result && typeof result === 'object' && 'objectTypes' in result) { + return (result as { objectTypes: Array<{ + id: number; + name: string; + description?: string; + objectCount?: number; + parentObjectTypeId?: number; + abstractObjectType?: boolean; + }> }).objectTypes; + } + return []; + } + + /** + * Get attributes for an object type + */ + async getAttributes(typeId: number): Promise> { + const url = `${this.baseUrl}/objecttype/${typeId}/attributes`; + const response = await fetch(url, { + headers: this.getHeaders(false), + }); + + if (!response.ok) { + logger.warn(`JiraAssetsClient: Failed to fetch attributes for type ${typeId}: ${response.status}`); + return []; + } + + return await response.json() as Array<{ + id: number; + name: string; + type: number; + typeValue?: string; + referenceObjectTypeId?: number; + referenceObjectType?: { id: number; name: string }; + minimumCardinality?: number; + maximumCardinality?: number; + editable?: boolean; + hidden?: boolean; + system?: boolean; + description?: string; + }>; + } +} + +// Export singleton instance +export const jiraAssetsClient = new JiraAssetsClient(); diff --git a/backend/src/repositories/ObjectCacheRepository.ts b/backend/src/repositories/ObjectCacheRepository.ts new file mode 100644 index 0000000..a29f0b8 --- /dev/null +++ b/backend/src/repositories/ObjectCacheRepository.ts @@ -0,0 +1,308 @@ +/** + * ObjectCacheRepository - Data access for cached objects (EAV pattern) + */ + +import type { DatabaseAdapter } from '../services/database/interface.js'; +import { logger } from '../services/logger.js'; + +export interface ObjectRecord { + id: string; + objectKey: string; + objectTypeName: string; + label: string; + jiraUpdatedAt: string | null; + jiraCreatedAt: string | null; + cachedAt: string; +} + +export interface AttributeValueRecord { + objectId: string; + attributeId: number; + textValue: string | null; + numberValue: number | null; + booleanValue: boolean | null; + dateValue: string | null; + datetimeValue: string | null; + referenceObjectId: string | null; + referenceObjectKey: string | null; + referenceObjectLabel: string | null; + arrayIndex: number; +} + +export interface ObjectRelationRecord { + sourceId: string; + targetId: string; + attributeId: number; + sourceType: string; + targetType: string; +} + +export class ObjectCacheRepository { + public db: DatabaseAdapter; + + constructor(db: DatabaseAdapter) { + this.db = db; + } + + /** + * Upsert an object record (minimal metadata) + */ + async upsertObject(object: { + id: string; + objectKey: string; + objectTypeName: string; + label: string; + jiraUpdatedAt?: string; + jiraCreatedAt?: string; + }): Promise { + const cachedAt = new Date().toISOString(); + await this.db.execute( + `INSERT INTO objects (id, object_key, object_type_name, label, jira_updated_at, jira_created_at, cached_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + object_key = excluded.object_key, + label = excluded.label, + jira_updated_at = excluded.jira_updated_at, + cached_at = excluded.cached_at`, + [ + object.id, + object.objectKey, + object.objectTypeName, + object.label, + object.jiraUpdatedAt || null, + object.jiraCreatedAt || null, + cachedAt, + ] + ); + } + + /** + * Get an object record by ID + */ + async getObject(objectId: string): Promise { + return await this.db.queryOne( + `SELECT id, object_key as objectKey, object_type_name as objectTypeName, label, + jira_updated_at as jiraUpdatedAt, jira_created_at as jiraCreatedAt, cached_at as cachedAt + FROM objects + WHERE id = ?`, + [objectId] + ); + } + + /** + * Get an object record by object key + */ + async getObjectByKey(objectKey: string): Promise { + return await this.db.queryOne( + `SELECT id, object_key as objectKey, object_type_name as objectTypeName, label, + jira_updated_at as jiraUpdatedAt, jira_created_at as jiraCreatedAt, cached_at as cachedAt + FROM objects + WHERE object_key = ?`, + [objectKey] + ); + } + + /** + * Delete all attribute values for an object + * Used when refreshing an object - we replace all attributes + */ + async deleteAttributeValues(objectId: string): Promise { + await this.db.execute( + `DELETE FROM attribute_values WHERE object_id = ?`, + [objectId] + ); + } + + /** + * Upsert a single attribute value + */ + async upsertAttributeValue(value: { + objectId: string; + attributeId: number; + textValue?: string | null; + numberValue?: number | null; + booleanValue?: boolean | null; + dateValue?: string | null; + datetimeValue?: string | null; + referenceObjectId?: string | null; + referenceObjectKey?: string | null; + referenceObjectLabel?: string | null; + arrayIndex: number; + }): Promise { + await this.db.execute( + `INSERT INTO attribute_values + (object_id, attribute_id, text_value, number_value, boolean_value, date_value, datetime_value, + reference_object_id, reference_object_key, reference_object_label, array_index) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(object_id, attribute_id, array_index) DO UPDATE SET + text_value = excluded.text_value, + number_value = excluded.number_value, + boolean_value = excluded.boolean_value, + date_value = excluded.date_value, + datetime_value = excluded.datetime_value, + reference_object_id = excluded.reference_object_id, + reference_object_key = excluded.reference_object_key, + reference_object_label = excluded.reference_object_label`, + [ + value.objectId, + value.attributeId, + value.textValue || null, + value.numberValue || null, + value.booleanValue || null, + value.dateValue || null, + value.datetimeValue || null, + value.referenceObjectId || null, + value.referenceObjectKey || null, + value.referenceObjectLabel || null, + value.arrayIndex, + ] + ); + } + + /** + * Batch upsert attribute values (much faster) + */ + async batchUpsertAttributeValues(values: Array<{ + objectId: string; + attributeId: number; + textValue?: string | null; + numberValue?: number | null; + booleanValue?: boolean | null; + dateValue?: string | null; + datetimeValue?: string | null; + referenceObjectId?: string | null; + referenceObjectKey?: string | null; + referenceObjectLabel?: string | null; + arrayIndex: number; + }>): Promise { + if (values.length === 0) return; + + await this.db.transaction(async (db) => { + for (const value of values) { + await db.execute( + `INSERT INTO attribute_values + (object_id, attribute_id, text_value, number_value, boolean_value, date_value, datetime_value, + reference_object_id, reference_object_key, reference_object_label, array_index) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(object_id, attribute_id, array_index) DO UPDATE SET + text_value = excluded.text_value, + number_value = excluded.number_value, + boolean_value = excluded.boolean_value, + date_value = excluded.date_value, + datetime_value = excluded.datetime_value, + reference_object_id = excluded.reference_object_id, + reference_object_key = excluded.reference_object_key, + reference_object_label = excluded.reference_object_label`, + [ + value.objectId, + value.attributeId, + value.textValue || null, + value.numberValue || null, + value.booleanValue || null, + value.dateValue || null, + value.datetimeValue || null, + value.referenceObjectId || null, + value.referenceObjectKey || null, + value.referenceObjectLabel || null, + value.arrayIndex, + ] + ); + } + }); + } + + /** + * Get all attribute values for an object + */ + async getAttributeValues(objectId: string): Promise { + return await this.db.query( + `SELECT object_id as objectId, attribute_id as attributeId, text_value as textValue, + number_value as numberValue, boolean_value as booleanValue, + date_value as dateValue, datetime_value as datetimeValue, + reference_object_id as referenceObjectId, reference_object_key as referenceObjectKey, + reference_object_label as referenceObjectLabel, array_index as arrayIndex + FROM attribute_values + WHERE object_id = ? + ORDER BY attribute_id, array_index`, + [objectId] + ); + } + + /** + * Upsert an object relation + */ + async upsertRelation(relation: { + sourceId: string; + targetId: string; + attributeId: number; + sourceType: string; + targetType: string; + }): Promise { + await this.db.execute( + `INSERT INTO object_relations (source_id, target_id, attribute_id, source_type, target_type) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(source_id, target_id, attribute_id) DO NOTHING`, + [ + relation.sourceId, + relation.targetId, + relation.attributeId, + relation.sourceType, + relation.targetType, + ] + ); + } + + /** + * Delete all relations for an object (used when refreshing) + */ + async deleteRelations(objectId: string): Promise { + await this.db.execute( + `DELETE FROM object_relations WHERE source_id = ?`, + [objectId] + ); + } + + /** + * Get objects of a specific type + */ + async getObjectsByType( + objectTypeName: string, + options: { + limit?: number; + offset?: number; + } = {} + ): Promise { + const { limit = 1000, offset = 0 } = options; + return await this.db.query( + `SELECT id, object_key as objectKey, object_type_name as objectTypeName, label, + jira_updated_at as jiraUpdatedAt, jira_created_at as jiraCreatedAt, cached_at as cachedAt + FROM objects + WHERE object_type_name = ? + ORDER BY label + LIMIT ? OFFSET ?`, + [objectTypeName, limit, offset] + ); + } + + /** + * Count objects of a type + */ + async countObjectsByType(objectTypeName: string): Promise { + const result = await this.db.queryOne<{ count: number | string }>( + `SELECT COUNT(*) as count FROM objects WHERE object_type_name = ?`, + [objectTypeName] + ); + if (!result?.count) return 0; + return typeof result.count === 'string' ? parseInt(result.count, 10) : Number(result.count); + } + + /** + * Delete an object (cascades to attribute_values and relations) + */ + async deleteObject(objectId: string): Promise { + await this.db.execute( + `DELETE FROM objects WHERE id = ?`, + [objectId] + ); + } +} diff --git a/backend/src/repositories/SchemaRepository.ts b/backend/src/repositories/SchemaRepository.ts new file mode 100644 index 0000000..9612830 --- /dev/null +++ b/backend/src/repositories/SchemaRepository.ts @@ -0,0 +1,485 @@ +/** + * SchemaRepository - Data access for schema metadata + */ + +import type { DatabaseAdapter } from '../services/database/interface.js'; +import { logger } from '../services/logger.js'; +import { toPascalCase } from '../services/schemaUtils.js'; + +export interface SchemaRecord { + id: number; + jiraSchemaId: string; + name: string; + description: string | null; + discoveredAt: string; + updatedAt: string; +} + +export interface ObjectTypeRecord { + id: number; + schemaId: number; + jiraTypeId: number; + typeName: string; + displayName: string; + description: string | null; + syncPriority: number; + objectCount: number; + enabled: boolean; + discoveredAt: string; + updatedAt: string; +} + +export interface AttributeRecord { + id: number; + jiraAttrId: number; + objectTypeName: string; + attrName: string; + fieldName: string; + attrType: string; + isMultiple: boolean; + isEditable: boolean; + isRequired: boolean; + isSystem: boolean; + referenceTypeName: string | null; + description: string | null; + discoveredAt: string; +} + +export class SchemaRepository { + constructor(private db: DatabaseAdapter) {} + + /** + * Upsert a schema + */ + async upsertSchema(schema: { + jiraSchemaId: string; + name: string; + description?: string; + }): Promise { + const now = new Date().toISOString(); + + // Check if exists + const existing = await this.db.queryOne<{ id: number }>( + `SELECT id FROM schemas WHERE jira_schema_id = ?`, + [schema.jiraSchemaId] + ); + + if (existing) { + await this.db.execute( + `UPDATE schemas SET name = ?, description = ?, updated_at = ? WHERE id = ?`, + [schema.name, schema.description || null, now, existing.id] + ); + return existing.id; + } else { + await this.db.execute( + `INSERT INTO schemas (jira_schema_id, name, description, discovered_at, updated_at) + VALUES (?, ?, ?, ?, ?)`, + [schema.jiraSchemaId, schema.name, schema.description || null, now, now] + ); + const result = await this.db.queryOne<{ id: number }>( + `SELECT id FROM schemas WHERE jira_schema_id = ?`, + [schema.jiraSchemaId] + ); + return result?.id || 0; + } + } + + /** + * Get all schemas + */ + async getAllSchemas(): Promise { + return await this.db.query( + `SELECT id, jira_schema_id as jiraSchemaId, name, description, discovered_at as discoveredAt, updated_at as updatedAt + FROM schemas + ORDER BY jira_schema_id` + ); + } + + /** + * Upsert an object type + */ + async upsertObjectType( + schemaId: number, + objectType: { + jiraTypeId: number; + typeName: string; + displayName: string; + description?: string; + syncPriority?: number; + objectCount?: number; + } + ): Promise { + const now = new Date().toISOString(); + + const existing = await this.db.queryOne<{ id: number }>( + `SELECT id FROM object_types WHERE schema_id = ? AND jira_type_id = ?`, + [schemaId, objectType.jiraTypeId] + ); + + if (existing) { + // Update existing record - ensure type_name is set if missing + // First check if type_name is NULL + const currentRecord = await this.db.queryOne<{ type_name: string | null }>( + `SELECT type_name FROM object_types WHERE id = ?`, + [existing.id] + ); + + // Determine what type_name value to use + let typeNameToUse: string | null = null; + if (objectType.typeName && objectType.typeName.trim() !== '') { + // Use provided typeName if available + typeNameToUse = objectType.typeName; + } else if (currentRecord?.type_name && currentRecord.type_name.trim() !== '') { + // Keep existing type_name if it exists and no new one provided + typeNameToUse = currentRecord.type_name; + } else { + // Generate type_name from display_name if missing + typeNameToUse = toPascalCase(objectType.displayName); + logger.warn(`SchemaRepository.upsertObjectType: Generated missing type_name "${typeNameToUse}" from display_name "${objectType.displayName}" for id=${existing.id}`); + } + + // Only update type_name if we have a valid value (never set to NULL) + if (typeNameToUse && typeNameToUse.trim() !== '') { + await this.db.execute( + `UPDATE object_types + SET display_name = ?, description = ?, sync_priority = ?, object_count = ?, + type_name = ?, updated_at = ? + WHERE id = ?`, + [ + objectType.displayName, + objectType.description || null, + objectType.syncPriority || 0, + objectType.objectCount || 0, + typeNameToUse, + now, + existing.id, + ] + ); + } else { + // Shouldn't happen, but log if it does + logger.error(`SchemaRepository.upsertObjectType: Cannot update type_name - all sources are empty for id=${existing.id}`); + // Still update other fields, but don't touch type_name + await this.db.execute( + `UPDATE object_types + SET display_name = ?, description = ?, sync_priority = ?, object_count = ?, + updated_at = ? + WHERE id = ?`, + [ + objectType.displayName, + objectType.description || null, + objectType.syncPriority || 0, + objectType.objectCount || 0, + now, + existing.id, + ] + ); + } + return existing.id; + } else { + await this.db.execute( + `INSERT INTO object_types (schema_id, jira_type_id, type_name, display_name, description, sync_priority, object_count, enabled, discovered_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + schemaId, + objectType.jiraTypeId, + objectType.typeName, + objectType.displayName, + objectType.description || null, + objectType.syncPriority || 0, + objectType.objectCount || 0, + false, // Default: disabled + now, + now, + ] + ); + const result = await this.db.queryOne<{ id: number }>( + `SELECT id FROM object_types WHERE schema_id = ? AND jira_type_id = ?`, + [schemaId, objectType.jiraTypeId] + ); + return result?.id || 0; + } + } + + /** + * Get enabled object types + */ + async getEnabledObjectTypes(): Promise { + // Handle both PostgreSQL (boolean) and SQLite (integer) for enabled column + const isPostgres = this.db.isPostgres === true; + + // For PostgreSQL: enabled is BOOLEAN, so 'enabled = true' works + // For SQLite: enabled is INTEGER (0/1), so 'enabled = 1' works + // However, some adapters might return booleans as 1/0 in both cases + // So we check for both boolean true and integer 1 + const enabledCondition = isPostgres + ? 'enabled IS true' // PostgreSQL: IS true is more explicit than = true + : 'enabled = 1'; // SQLite: explicit integer comparison + + // Query without aliases first to ensure we get the raw values + const rawResults = await this.db.query<{ + id: number; + schema_id: number; + jira_type_id: number; + type_name: string | null; + display_name: string; + description: string | null; + sync_priority: number; + object_count: number; + enabled: boolean | number; + discovered_at: string; + updated_at: string; + }>( + `SELECT id, schema_id, jira_type_id, type_name, display_name, description, + sync_priority, object_count, enabled, discovered_at, updated_at + FROM object_types + WHERE ${enabledCondition} + ORDER BY sync_priority, type_name` + ); + + logger.debug(`SchemaRepository.getEnabledObjectTypes: Raw query found ${rawResults.length} enabled types. Raw type_name values: ${JSON.stringify(rawResults.map(r => ({ id: r.id, type_name: r.type_name, type_name_type: typeof r.type_name, display_name: r.display_name })))}`); + + // Map to ObjectTypeRecord format manually to ensure proper mapping + const results: ObjectTypeRecord[] = rawResults.map(r => ({ + id: r.id, + schemaId: r.schema_id, + jiraTypeId: r.jira_type_id, + typeName: r.type_name || '', // Convert null to empty string if needed + displayName: r.display_name, + description: r.description, + syncPriority: r.sync_priority, + objectCount: r.object_count, + enabled: r.enabled === true || r.enabled === 1, + discoveredAt: r.discovered_at, + updatedAt: r.updated_at, + })); + + // Debug: Log what we found + logger.debug(`SchemaRepository.getEnabledObjectTypes: Found ${results.length} enabled types (isPostgres: ${isPostgres}, condition: ${enabledCondition})`); + if (results.length > 0) { + // Log raw results to see what we're actually getting + logger.debug(`SchemaRepository.getEnabledObjectTypes: Raw results: ${JSON.stringify(results.map(r => ({ + id: r.id, + typeName: r.typeName, + typeNameType: typeof r.typeName, + typeNameLength: r.typeName?.length, + displayName: r.displayName, + enabled: r.enabled + })))}`); + + // Check for missing typeName + const missingTypeName = results.filter(r => !r.typeName || r.typeName.trim() === ''); + if (missingTypeName.length > 0) { + logger.error(`SchemaRepository.getEnabledObjectTypes: Found ${missingTypeName.length} enabled types with missing typeName: ${JSON.stringify(missingTypeName.map(r => ({ + id: r.id, + jiraTypeId: r.jiraTypeId, + displayName: r.displayName, + typeName: r.typeName, + typeNameType: typeof r.typeName, + rawTypeName: JSON.stringify(r.typeName) + })))}`); + + // Try to query directly to see what the DB actually has + for (const missing of missingTypeName) { + const directCheck = await this.db.queryOne<{ type_name: string | null }>( + `SELECT type_name FROM object_types WHERE id = ?`, + [missing.id] + ); + logger.error(`SchemaRepository.getEnabledObjectTypes: Direct query for id=${missing.id} returned type_name: ${JSON.stringify(directCheck?.type_name)}`); + } + } + logger.debug(`SchemaRepository.getEnabledObjectTypes: Type names: ${results.map(r => `${r.typeName || 'NULL'}(enabled:${r.enabled}, type:${typeof r.enabled})`).join(', ')}`); + + // Also check what gets filtered out + const filteredResults = results.filter(r => r.typeName && r.typeName.trim() !== ''); + if (filteredResults.length < results.length) { + logger.warn(`SchemaRepository.getEnabledObjectTypes: Filtered out ${results.length - filteredResults.length} results with missing typeName`); + } + } else { + // Debug: Check if there are any enabled types at all (check the actual query) + const enabledCheck = await this.db.query<{ count: number }>( + isPostgres + ? `SELECT COUNT(*) as count FROM object_types WHERE enabled IS true` + : `SELECT COUNT(*) as count FROM object_types WHERE enabled = 1` + ); + logger.warn(`SchemaRepository.getEnabledObjectTypes: No enabled types found with query. Query found ${enabledCheck[0]?.count || 0} enabled types.`); + + // Also check what types are actually in the DB + const allTypes = await this.db.query<{ typeName: string; enabled: boolean | number; id: number }>( + `SELECT id, type_name as typeName, enabled FROM object_types WHERE enabled IS NOT NULL ORDER BY enabled DESC LIMIT 10` + ); + logger.warn(`SchemaRepository.getEnabledObjectTypes: Sample types from DB: ${allTypes.map(t => `id=${t.id}, ${t.typeName || 'NULL'}=enabled:${t.enabled}(${typeof t.enabled})`).join(', ')}`); + } + + // Filter out results with missing typeName + return results.filter(r => r.typeName && r.typeName.trim() !== ''); + } + + /** + * Get object type by type name + */ + async getObjectTypeByTypeName(typeName: string): Promise { + return await this.db.queryOne( + `SELECT id, schema_id as schemaId, jira_type_id as jiraTypeId, type_name as typeName, + display_name as displayName, description, sync_priority as syncPriority, + object_count as objectCount, enabled, discovered_at as discoveredAt, updated_at as updatedAt + FROM object_types + WHERE type_name = ?`, + [typeName] + ); + } + + /** + * Get object type by Jira type ID + * Note: Jira type IDs are global across schemas, but we store them per schema. + * This method returns the first matching type found (any schema). + */ + async getObjectTypeByJiraId(jiraTypeId: number): Promise { + const result = await this.db.queryOne( + `SELECT id, schema_id as schemaId, jira_type_id as jiraTypeId, type_name as typeName, + display_name as displayName, description, sync_priority as syncPriority, + object_count as objectCount, enabled, discovered_at as discoveredAt, updated_at as updatedAt + FROM object_types + WHERE jira_type_id = ? + LIMIT 1`, + [jiraTypeId] + ); + + if (!result) { + // Diagnostic: Check if this type ID exists in any schema + const db = this.db; + try { + const allSchemasWithType = await db.query<{ schema_id: number; jira_schema_id: string; schema_name: string; count: number }>( + `SELECT ot.schema_id, s.jira_schema_id, s.name as schema_name, COUNT(*) as count + FROM object_types ot + JOIN schemas s ON ot.schema_id = s.id + WHERE ot.jira_type_id = ? + GROUP BY ot.schema_id, s.jira_schema_id, s.name`, + [jiraTypeId] + ); + + if (allSchemasWithType.length === 0) { + logger.debug(`SchemaRepository: Jira type ID ${jiraTypeId} not found in any schema. This object type needs to be discovered via schema discovery.`); + } else { + logger.debug(`SchemaRepository: Jira type ID ${jiraTypeId} exists in ${allSchemasWithType.length} schema(s): ${allSchemasWithType.map(s => `${s.schema_name} (ID: ${s.jira_schema_id})`).join(', ')}`); + } + } catch (error) { + logger.debug(`SchemaRepository: Failed to check schema existence for type ID ${jiraTypeId}`, error); + } + } + + return result; + } + + /** + * Upsert an attribute + */ + async upsertAttribute(attribute: { + jiraAttrId: number; + objectTypeName: string; + attrName: string; + fieldName: string; + attrType: string; + isMultiple: boolean; + isEditable: boolean; + isRequired: boolean; + isSystem: boolean; + referenceTypeName?: string; + description?: string; + }): Promise { + const now = new Date().toISOString(); + + const existing = await this.db.queryOne<{ id: number }>( + `SELECT id FROM attributes WHERE jira_attr_id = ? AND object_type_name = ?`, + [attribute.jiraAttrId, attribute.objectTypeName] + ); + + if (existing) { + await this.db.execute( + `UPDATE attributes + SET attr_name = ?, field_name = ?, attr_type = ?, is_multiple = ?, is_editable = ?, + is_required = ?, is_system = ?, reference_type_name = ?, description = ? + WHERE id = ?`, + [ + attribute.attrName, + attribute.fieldName, + attribute.attrType, + attribute.isMultiple, + attribute.isEditable, + attribute.isRequired, + attribute.isSystem, + attribute.referenceTypeName || null, + attribute.description || null, + existing.id, + ] + ); + return existing.id; + } else { + await this.db.execute( + `INSERT INTO attributes (jira_attr_id, object_type_name, attr_name, field_name, attr_type, + is_multiple, is_editable, is_required, is_system, reference_type_name, description, discovered_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + attribute.jiraAttrId, + attribute.objectTypeName, + attribute.attrName, + attribute.fieldName, + attribute.attrType, + attribute.isMultiple, + attribute.isEditable, + attribute.isRequired, + attribute.isSystem, + attribute.referenceTypeName || null, + attribute.description || null, + now, + ] + ); + const result = await this.db.queryOne<{ id: number }>( + `SELECT id FROM attributes WHERE jira_attr_id = ? AND object_type_name = ?`, + [attribute.jiraAttrId, attribute.objectTypeName] + ); + return result?.id || 0; + } + } + + /** + * Get attributes for an object type + */ + async getAttributesForType(objectTypeName: string): Promise { + return await this.db.query( + `SELECT id, jira_attr_id as jiraAttrId, object_type_name as objectTypeName, attr_name as attrName, + field_name as fieldName, attr_type as attrType, is_multiple as isMultiple, + is_editable as isEditable, is_required as isRequired, is_system as isSystem, + reference_type_name as referenceTypeName, description, discovered_at as discoveredAt + FROM attributes + WHERE object_type_name = ? + ORDER BY jira_attr_id`, + [objectTypeName] + ); + } + + /** + * Get attribute by object type and field name + */ + async getAttributeByFieldName(objectTypeName: string, fieldName: string): Promise { + return await this.db.queryOne( + `SELECT id, jira_attr_id as jiraAttrId, object_type_name as objectTypeName, attr_name as attrName, + field_name as fieldName, attr_type as attrType, is_multiple as isMultiple, + is_editable as isEditable, is_required as isRequired, is_system as isSystem, + reference_type_name as referenceTypeName, description, discovered_at as discoveredAt + FROM attributes + WHERE object_type_name = ? AND field_name = ?`, + [objectTypeName, fieldName] + ); + } + + /** + * Get attribute ID by object type and Jira attribute ID + */ + async getAttributeId(objectTypeName: string, jiraAttrId: number): Promise { + const result = await this.db.queryOne<{ id: number }>( + `SELECT id FROM attributes WHERE object_type_name = ? AND jira_attr_id = ?`, + [objectTypeName, jiraAttrId] + ); + return result?.id || null; + } +} diff --git a/backend/src/routes/applications.ts b/backend/src/routes/applications.ts index 043a923..b6cd17b 100644 --- a/backend/src/routes/applications.ts +++ b/backend/src/routes/applications.ts @@ -326,8 +326,9 @@ router.get('/bia-comparison', async (req: Request, res: Response) => { // Query params: // - mode=edit: Force refresh from Jira for editing (includes _jiraUpdatedAt for conflict detection) router.get('/:id', async (req: Request, res: Response) => { + const id = getParamString(req, 'id'); + try { - const id = getParamString(req, 'id'); const mode = getQueryString(req, 'mode'); // Don't treat special routes as application IDs @@ -342,7 +343,7 @@ router.get('/:id', async (req: Request, res: Response) => { : await dataService.getApplicationById(id); if (!application) { - res.status(404).json({ error: 'Application not found' }); + res.status(404).json({ error: 'Application not found', id }); return; } @@ -355,8 +356,15 @@ router.get('/:id', async (req: Request, res: Response) => { res.json(applicationWithCompleteness); } catch (error) { - logger.error('Failed to get application', error); - res.status(500).json({ error: 'Failed to get application' }); + logger.error(`Failed to get application ${id}`, error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const errorDetails = error instanceof Error && error.stack ? error.stack : String(error); + logger.debug(`Error details for application ${id}:`, errorDetails); + res.status(500).json({ + error: 'Failed to get application', + details: errorMessage, + id: id, + }); } }); @@ -625,34 +633,101 @@ router.get('/:id/related/:objectType', async (req: Request, res: Response) => { type RelatedObjectType = Server | Flows | Certificate | Domain | AzureSubscription; let relatedObjects: RelatedObjectType[] = []; + // Get requested attributes from query string (needed for fallback) + const attributesParam = getQueryString(req, 'attributes'); + const requestedAttrs = attributesParam + ? attributesParam.split(',').map(a => a.trim()) + : []; + + logger.debug(`Getting related objects for application ${id}, objectType: ${objectType}, typeName: ${typeName}, requestedAttrs: ${requestedAttrs.join(',') || 'none'}`); + + // First try to get from cache switch (typeName) { case 'Server': relatedObjects = await cmdbService.getReferencingObjects(id, 'Server'); + logger.debug(`Found ${relatedObjects.length} Servers referencing application ${id} in cache`); break; case 'Flows': { // Flows reference ApplicationComponents via Source and Target attributes // We need to find Flows where this ApplicationComponent is the target of the reference relatedObjects = await cmdbService.getReferencingObjects(id, 'Flows'); + logger.debug(`Found ${relatedObjects.length} Flows referencing application ${id} in cache`); break; } case 'Certificate': relatedObjects = await cmdbService.getReferencingObjects(id, 'Certificate'); + logger.debug(`Found ${relatedObjects.length} Certificates referencing application ${id} in cache`); break; case 'Domain': relatedObjects = await cmdbService.getReferencingObjects(id, 'Domain'); + logger.debug(`Found ${relatedObjects.length} Domains referencing application ${id} in cache`); break; case 'AzureSubscription': relatedObjects = await cmdbService.getReferencingObjects(id, 'AzureSubscription'); + logger.debug(`Found ${relatedObjects.length} AzureSubscriptions referencing application ${id} in cache`); break; default: relatedObjects = []; + logger.warn(`Unknown object type for related objects: ${typeName}`); + } + + // If no objects found in cache, try to fetch from Jira directly as fallback + // This helps when relations haven't been synced yet + if (relatedObjects.length === 0) { + try { + // Get application to get its objectKey + const app = await cmdbService.getObject('ApplicationComponent', id); + if (!app) { + logger.warn(`Application ${id} not found in cache, cannot fetch related objects from Jira`); + } else if (!app.objectKey) { + logger.warn(`Application ${id} has no objectKey, cannot fetch related objects from Jira`); + } else { + logger.info(`No related ${typeName} objects found in cache for application ${id} (${app.objectKey}), trying Jira directly...`); + const { jiraAssetsService } = await import('../services/jiraAssets.js'); + // Use the Jira object type name from schema (not our internal typeName) + const { OBJECT_TYPES } = await import('../generated/jira-schema.js'); + const jiraTypeDef = OBJECT_TYPES[typeName]; + const jiraObjectTypeName = jiraTypeDef?.name || objectType; + logger.debug(`Using Jira object type name: "${jiraObjectTypeName}" for internal type "${typeName}"`); + const jiraResult = await jiraAssetsService.getRelatedObjects(app.objectKey, jiraObjectTypeName, requestedAttrs); + + logger.debug(`Jira query returned ${jiraResult?.objects?.length || 0} objects`); + + if (jiraResult && jiraResult.objects && jiraResult.objects.length > 0) { + logger.info(`Found ${jiraResult.objects.length} related ${typeName} objects from Jira, caching them...`); + + // Batch fetch and cache all objects at once (much more efficient) + const objectIds = jiraResult.objects.map(obj => obj.id.toString()); + const cachedObjects = await cmdbService.batchFetchAndCacheObjects(typeName as CMDBObjectTypeName, objectIds); + + logger.info(`Successfully batch cached ${cachedObjects.length} of ${jiraResult.objects.length} related ${typeName} objects`); + + // Use cached objects, fallback to minimal objects from Jira result if not found + const cachedById = new Map(cachedObjects.map(obj => [obj.id, obj])); + relatedObjects = jiraResult.objects.map((jiraObj) => { + const cached = cachedById.get(jiraObj.id.toString()); + if (cached) { + return cached as RelatedObjectType; + } + // Fallback: create minimal object from Jira result + logger.debug(`Creating minimal object for ${jiraObj.id} (${jiraObj.key}) as cache lookup failed`); + return { + id: jiraObj.id.toString(), + objectKey: jiraObj.key, + label: jiraObj.label, + _objectType: typeName, + } as RelatedObjectType; + }); + + logger.info(`Loaded ${relatedObjects.length} related ${typeName} objects (${relatedObjects.filter(o => o).length} valid)`); + } else { + logger.info(`No related ${typeName} objects found in Jira for application ${app.objectKey}`); + } + } + } catch (error) { + logger.error(`Failed to fetch related ${typeName} objects from Jira as fallback for application ${id}:`, error); + } } - - // Get requested attributes from query string - const attributesParam = getQueryString(req, 'attributes'); - const requestedAttrs = attributesParam - ? attributesParam.split(',').map(a => a.trim()) - : []; // Format response - must match RelatedObjectsResponse type expected by frontend const objects = relatedObjects.map(obj => { diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 5249205..3bea344 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -84,7 +84,11 @@ router.get('/me', async (req: Request, res: Response) => { // The sessionId should already be set by authMiddleware from cookies const sessionId = req.sessionId || req.headers['x-session-id'] as string || req.cookies?.sessionId; - logger.debug(`[GET /me] SessionId: ${sessionId ? sessionId.substring(0, 8) + '...' : 'none'}, Cookies: ${JSON.stringify(req.cookies)}`); + // Only log relevant cookies to avoid noise from other applications + const relevantCookies = req.cookies ? { + sessionId: req.cookies.sessionId ? req.cookies.sessionId.substring(0, 8) + '...' : undefined, + } : {}; + logger.debug(`[GET /me] SessionId: ${sessionId ? sessionId.substring(0, 8) + '...' : 'none'}, Relevant cookies: ${JSON.stringify(relevantCookies)}`); // Service accounts are NOT used for application authentication // They are only used for Jira API access (configured in .env as JIRA_SERVICE_ACCOUNT_TOKEN) @@ -456,9 +460,11 @@ router.post('/accept-invitation', async (req: Request, res: Response) => { export async function authMiddleware(req: Request, res: Response, next: NextFunction) { const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId; - // Debug logging for cookie issues + // Debug logging for cookie issues (only log relevant cookies to avoid noise) if (req.path === '/api/auth/me') { - logger.debug(`[authMiddleware] Path: ${req.path}, Cookies: ${JSON.stringify(req.cookies)}, SessionId from cookie: ${req.cookies?.sessionId}, SessionId from header: ${req.headers['x-session-id']}`); + const sessionIdFromCookie = req.cookies?.sessionId ? req.cookies.sessionId.substring(0, 8) + '...' : 'none'; + const sessionIdFromHeader = req.headers['x-session-id'] ? String(req.headers['x-session-id']).substring(0, 8) + '...' : 'none'; + logger.debug(`[authMiddleware] Path: ${req.path}, SessionId from cookie: ${sessionIdFromCookie}, SessionId from header: ${sessionIdFromHeader}`); } if (sessionId) { diff --git a/backend/src/routes/cache.ts b/backend/src/routes/cache.ts index adb3a87..3de48ff 100644 --- a/backend/src/routes/cache.ts +++ b/backend/src/routes/cache.ts @@ -5,7 +5,7 @@ */ import { Router, Request, Response } from 'express'; -import { cacheStore } from '../services/cacheStore.js'; +import { normalizedCacheStore as cacheStore } from '../services/normalizedCacheStore.js'; import { syncEngine } from '../services/syncEngine.js'; import { logger } from '../services/logger.js'; import { requireAuth, requirePermission } from '../middleware/authorization.js'; @@ -30,17 +30,24 @@ router.get('/status', async (req: Request, res: Response) => { if (cacheStats.objectsByType['ApplicationComponent'] !== undefined) { try { const { jiraAssetsClient } = await import('../services/jiraAssetsClient.js'); + const { schemaMappingService } = await import('../services/schemaMappingService.js'); const { OBJECT_TYPES } = await import('../generated/jira-schema.js'); const typeDef = OBJECT_TYPES['ApplicationComponent']; if (typeDef) { - const searchResult = await jiraAssetsClient.searchObjects(`objectType = "${typeDef.name}"`, 1, 1); - const jiraCount = searchResult.totalCount; - const cacheCount = cacheStats.objectsByType['ApplicationComponent'] || 0; - jiraComparison = { - jiraCount, - cacheCount, - difference: jiraCount - cacheCount, - }; + // Get schema ID for ApplicationComponent + const schemaId = await schemaMappingService.getSchemaId('ApplicationComponent'); + + // Skip if no schema ID is available + if (schemaId && schemaId.trim() !== '') { + const searchResult = await jiraAssetsClient.searchObjects(`objectType = "${typeDef.name}"`, 1, 1, schemaId); + const jiraCount = searchResult.totalCount; + const cacheCount = cacheStats.objectsByType['ApplicationComponent'] || 0; + jiraComparison = { + jiraCount, + cacheCount, + difference: jiraCount - cacheCount, + }; + } } } catch (err) { logger.debug('Could not fetch Jira count for comparison', err); @@ -64,6 +71,17 @@ router.post('/sync', async (req: Request, res: Response) => { try { logger.info('Manual full sync triggered'); + // Check if configuration is complete + const { schemaConfigurationService } = await import('../services/schemaConfigurationService.js'); + const isConfigured = await schemaConfigurationService.isConfigurationComplete(); + if (!isConfigured) { + res.status(400).json({ + error: 'Schema configuration not complete', + message: 'Please configure at least one object type to be synced in the settings page before starting sync.', + }); + return; + } + // Don't wait for completion - return immediately syncEngine.fullSync().catch(err => { logger.error('Full sync failed', err); @@ -75,7 +93,11 @@ router.post('/sync', async (req: Request, res: Response) => { }); } catch (error) { logger.error('Failed to trigger full sync', error); - res.status(500).json({ error: 'Failed to trigger sync' }); + const errorMessage = error instanceof Error ? error.message : 'Failed to trigger sync'; + res.status(500).json({ + error: errorMessage, + details: error instanceof Error ? error.stack : undefined + }); } }); @@ -116,6 +138,39 @@ router.post('/sync/:objectType', async (req: Request, res: Response) => { } }); +// Refresh a specific application (force re-sync from Jira) +router.post('/refresh-application/:id', async (req: Request, res: Response) => { + try { + const id = getParamString(req, 'id'); + const { cmdbService } = await import('../services/cmdbService.js'); + + logger.info(`Manual refresh triggered for application ${id}`); + + // Force refresh from Jira + const app = await cmdbService.getObject('ApplicationComponent', id, { forceRefresh: true }); + + if (!app) { + res.status(404).json({ error: `Application ${id} not found in Jira` }); + return; + } + + res.json({ + status: 'refreshed', + applicationId: id, + applicationKey: app.objectKey, + message: 'Application refreshed from Jira and cached with updated schema', + }); + } catch (error) { + const id = getParamString(req, 'id'); + const errorMessage = error instanceof Error ? error.message : 'Failed to refresh application'; + logger.error(`Failed to refresh application ${id}`, error); + res.status(500).json({ + error: errorMessage, + applicationId: id, + }); + } +}); + // Clear cache for a specific type router.delete('/clear/:objectType', async (req: Request, res: Response) => { try { diff --git a/backend/src/routes/dataValidation.ts b/backend/src/routes/dataValidation.ts new file mode 100644 index 0000000..585d8e7 --- /dev/null +++ b/backend/src/routes/dataValidation.ts @@ -0,0 +1,488 @@ +/** + * Data Validation routes + * + * Provides endpoints for validating and inspecting data in the cache/database. + */ + +import { Router, Request, Response } from 'express'; +import { normalizedCacheStore as cacheStore } from '../services/normalizedCacheStore.js'; +import { logger } from '../services/logger.js'; +import { requireAuth, requirePermission } from '../middleware/authorization.js'; +import { getQueryString, getParamString } from '../utils/queryHelpers.js'; +import { schemaCacheService } from '../services/schemaCacheService.js'; +import { jiraAssetsClient } from '../services/jiraAssetsClient.js'; +import { dataIntegrityService } from '../services/dataIntegrityService.js'; +import { schemaMappingService } from '../services/schemaMappingService.js'; +import { getDatabaseAdapter } from '../services/database/singleton.js'; +import type { CMDBObjectTypeName } from '../generated/jira-types.js'; + +const router = Router(); + +// All routes require authentication and manage_settings permission +router.use(requireAuth); +router.use(requirePermission('manage_settings')); + +/** + * GET /api/data-validation/stats + * Get comprehensive data validation statistics + */ +router.get('/stats', async (req: Request, res: Response) => { + try { + const db = getDatabaseAdapter(); + const cacheStats = await cacheStore.getStats(); + + // Get object counts by type from cache + const objectsByType = cacheStats.objectsByType; + + // Get schema from database (via cache) + const schema = await schemaCacheService.getSchema(); + const objectTypes = schema.objectTypes; + const typeNames = Object.keys(objectTypes); + + // Get schema information for each object type (join with schemas table) + const schemaInfoMap = new Map(); + try { + const schemaInfoRows = await db.query<{ + type_name: string; + jira_schema_id: string; + schema_name: string; + }>(` + SELECT ot.type_name, s.jira_schema_id, s.name as schema_name + FROM object_types ot + JOIN schemas s ON ot.schema_id = s.id + WHERE ot.type_name IN (${typeNames.map(() => '?').join(',')}) + `, typeNames); + + for (const row of schemaInfoRows) { + schemaInfoMap.set(row.type_name, { + schemaId: row.jira_schema_id, + schemaName: row.schema_name, + }); + } + } catch (error) { + logger.debug('Failed to fetch schema information', error); + } + + // Get Jira counts for comparison + const jiraCounts: Record = {}; + + // Fetch counts from Jira in parallel, using schema IDs from database + const countPromises = typeNames.map(async (typeName) => { + try { + // Get schema ID from the database (already fetched above) + const schemaInfo = schemaInfoMap.get(typeName); + + // If no schema info from database, try schemaMappingService as fallback + let schemaId: string | undefined = schemaInfo?.schemaId; + if (!schemaId || schemaId.trim() === '') { + schemaId = await schemaMappingService.getSchemaId(typeName); + } + + // Skip if no schema ID is available (object type not configured) + if (!schemaId || schemaId.trim() === '') { + logger.debug(`No schema ID configured for ${typeName}, skipping Jira count`); + jiraCounts[typeName] = 0; + return { typeName, count: 0 }; + } + + const count = await jiraAssetsClient.getObjectCount(typeName, schemaId); + jiraCounts[typeName] = count; + return { typeName, count }; + } catch (error) { + logger.debug(`Failed to get Jira count for ${typeName}`, error); + jiraCounts[typeName] = 0; + return { typeName, count: 0 }; + } + }); + + await Promise.all(countPromises); + + // Calculate differences + const typeComparisons: Array<{ + typeName: string; + typeDisplayName: string; + schemaId?: string; + schemaName?: string; + cacheCount: number; + jiraCount: number; + difference: number; + syncStatus: 'synced' | 'outdated' | 'missing'; + }> = []; + + for (const [typeName, typeDef] of Object.entries(objectTypes)) { + const cacheCount = objectsByType[typeName] || 0; + const jiraCount = jiraCounts[typeName] || 0; + const difference = jiraCount - cacheCount; + + let syncStatus: 'synced' | 'outdated' | 'missing'; + if (cacheCount === 0 && jiraCount > 0) { + syncStatus = 'missing'; + } else if (difference > 0) { + syncStatus = 'outdated'; + } else { + syncStatus = 'synced'; + } + + const schemaInfo = schemaInfoMap.get(typeName); + + typeComparisons.push({ + typeName, + typeDisplayName: typeDef.name, + schemaId: schemaInfo?.schemaId, + schemaName: schemaInfo?.schemaName, + cacheCount, + jiraCount, + difference, + syncStatus, + }); + } + + // Sort by difference (most outdated first) + typeComparisons.sort((a, b) => b.difference - a.difference); + + // Get relation statistics + const relationStats = { + total: cacheStats.totalRelations, + // Could add more detailed relation stats here + }; + + // Check for broken references (references to objects that don't exist) + let brokenReferences = 0; + try { + brokenReferences = await cacheStore.getBrokenReferencesCount(); + } catch (error) { + logger.debug('Could not check for broken references', error); + } + + // Get objects with missing required attributes + // This would require schema information, so we'll skip for now + + res.json({ + cache: { + totalObjects: cacheStats.totalObjects, + totalRelations: cacheStats.totalRelations, + objectsByType, + isWarm: cacheStats.isWarm, + dbSizeBytes: cacheStats.dbSizeBytes, + lastFullSync: cacheStats.lastFullSync, + lastIncrementalSync: cacheStats.lastIncrementalSync, + }, + jira: { + counts: jiraCounts, + }, + comparison: { + typeComparisons, + totalOutdated: typeComparisons.filter(t => t.syncStatus === 'outdated').length, + totalMissing: typeComparisons.filter(t => t.syncStatus === 'missing').length, + totalSynced: typeComparisons.filter(t => t.syncStatus === 'synced').length, + }, + validation: { + brokenReferences, + // Add more validation metrics here + }, + relations: relationStats, + }); + } catch (error) { + logger.error('Failed to get data validation stats', error); + res.status(500).json({ error: 'Failed to get data validation stats' }); + } +}); + +/** + * GET /api/data-validation/objects/:typeName + * Get sample objects of a specific type for inspection + */ +router.get('/objects/:typeName', async (req: Request, res: Response) => { + try { + const typeName = getParamString(req, 'typeName'); + const limit = parseInt(getQueryString(req, 'limit') || '10', 10); + const offset = parseInt(getQueryString(req, 'offset') || '0', 10); + + // Get schema from database (via cache) + const schema = await schemaCacheService.getSchema(); + const objectTypes = schema.objectTypes; + + if (!objectTypes[typeName]) { + res.status(400).json({ + error: `Unknown object type: ${typeName}`, + supportedTypes: Object.keys(objectTypes), + }); + return; + } + + const objects = await cacheStore.getObjects(typeName as CMDBObjectTypeName, { limit, offset }); + const total = await cacheStore.countObjects(typeName as CMDBObjectTypeName); + + res.json({ + typeName, + typeDisplayName: objectTypes[typeName].name, + objects, + pagination: { + limit, + offset, + total, + hasMore: offset + limit < total, + }, + }); + } catch (error) { + const typeName = getParamString(req, 'typeName'); + logger.error(`Failed to get objects for type ${typeName}`, error); + res.status(500).json({ error: 'Failed to get objects' }); + } +}); + +/** + * GET /api/data-validation/object/:id + * Get a specific object by ID for inspection + */ +router.get('/object/:id', async (req: Request, res: Response) => { + try { + const id = getParamString(req, 'id'); + + // Try to find the object in any type + // First, get the object's metadata + const objRow = await cacheStore.getObjectMetadata(id); + + if (!objRow) { + res.status(404).json({ error: `Object ${id} not found in cache` }); + return; + } + + // Get schema from database (via cache) + const schema = await schemaCacheService.getSchema(); + const objectTypes = schema.objectTypes; + + const object = await cacheStore.getObject(objRow.object_type_name as any, id); + + if (!object) { + res.status(404).json({ error: `Object ${id} could not be reconstructed` }); + return; + } + + res.json({ + object, + metadata: { + typeName: objRow.object_type_name, + typeDisplayName: objectTypes[objRow.object_type_name]?.name || objRow.object_type_name, + objectKey: objRow.object_key, + label: objRow.label, + }, + }); + } catch (error) { + const id = getParamString(req, 'id'); + logger.error(`Failed to get object ${id}`, error); + res.status(500).json({ error: 'Failed to get object' }); + } +}); + +/** + * GET /api/data-validation/broken-references + * Get list of broken references (references to objects that don't exist) + */ +router.get('/broken-references', async (req: Request, res: Response) => { + try { + const limit = parseInt(getQueryString(req, 'limit') || '50', 10); + const offset = parseInt(getQueryString(req, 'offset') || '0', 10); + + // Get broken references with details + const brokenRefs = await cacheStore.getBrokenReferences(limit, offset); + + // Get total count + const total = await cacheStore.getBrokenReferencesCount(); + + res.json({ + brokenReferences: brokenRefs, + pagination: { + limit, + offset, + total, + hasMore: offset + limit < total, + }, + }); + } catch (error) { + logger.error('Failed to get broken references', error); + res.status(500).json({ error: 'Failed to get broken references' }); + } +}); + +/** + * POST /api/data-validation/repair-broken-references + * Repair broken references + * + * Query params: + * - mode: 'delete' | 'fetch' | 'dry-run' (default: 'fetch') + * - batchSize: number (default: 100) + * - maxRepairs: number (default: 0 = unlimited) + */ +router.post('/repair-broken-references', async (req: Request, res: Response) => { + try { + const mode = (getQueryString(req, 'mode') || 'fetch') as 'delete' | 'fetch' | 'dry-run'; + const batchSize = parseInt(getQueryString(req, 'batchSize') || '100', 10); + const maxRepairs = parseInt(getQueryString(req, 'maxRepairs') || '0', 10); + + if (!['delete', 'fetch', 'dry-run'].includes(mode)) { + res.status(400).json({ error: 'Invalid mode. Must be: delete, fetch, or dry-run' }); + return; + } + + logger.info(`DataValidation: Starting repair broken references (mode: ${mode}, batchSize: ${batchSize}, maxRepairs: ${maxRepairs})`); + + const result = await dataIntegrityService.repairBrokenReferences(mode, batchSize, maxRepairs); + + res.json({ + status: 'completed', + mode, + result, + }); + } catch (error) { + logger.error('Failed to repair broken references', error); + res.status(500).json({ error: 'Failed to repair broken references' }); + } +}); + +/** + * POST /api/data-validation/full-integrity-check + * Run full integrity check and optionally repair + * + * Query params: + * - repair: boolean (default: false) + */ +router.post('/full-integrity-check', async (req: Request, res: Response) => { + try { + const repair = getQueryString(req, 'repair') === 'true'; + + logger.info(`DataValidation: Starting full integrity check (repair: ${repair})`); + + const result = await dataIntegrityService.fullIntegrityCheck(repair); + + res.json({ + status: 'completed', + result, + }); + } catch (error) { + logger.error('Failed to run full integrity check', error); + res.status(500).json({ error: 'Failed to run full integrity check' }); + } +}); + +/** + * GET /api/data-validation/validation-status + * Get current validation status + */ +router.get('/validation-status', async (req: Request, res: Response) => { + try { + const status = await dataIntegrityService.validateReferences(); + res.json(status); + } catch (error) { + logger.error('Failed to get validation status', error); + res.status(500).json({ error: 'Failed to get validation status' }); + } +}); + +/** + * GET /api/data-validation/schema-mappings + * Get all schema mappings + */ +router.get('/schema-mappings', async (req: Request, res: Response) => { + try { + const mappings = await schemaMappingService.getAllMappings(); + res.json({ mappings }); + } catch (error) { + logger.error('Failed to get schema mappings', error); + res.status(500).json({ error: 'Failed to get schema mappings' }); + } +}); + +/** + * POST /api/data-validation/schema-mappings + * Create or update a schema mapping + */ +router.post('/schema-mappings', async (req: Request, res: Response) => { + try { + const { objectTypeName, schemaId, enabled = true } = req.body; + + if (!objectTypeName || !schemaId) { + res.status(400).json({ error: 'objectTypeName and schemaId are required' }); + return; + } + + await schemaMappingService.setMapping(objectTypeName, schemaId, enabled); + schemaMappingService.clearCache(); // Clear cache to reload + + res.json({ + status: 'success', + message: `Schema mapping updated for ${objectTypeName}`, + }); + } catch (error) { + logger.error('Failed to set schema mapping', error); + res.status(500).json({ error: 'Failed to set schema mapping' }); + } +}); + +/** + * DELETE /api/data-validation/schema-mappings/:objectTypeName + * Delete a schema mapping (will use default schema) + */ +router.delete('/schema-mappings/:objectTypeName', async (req: Request, res: Response) => { + try { + const objectTypeName = getParamString(req, 'objectTypeName'); + await schemaMappingService.deleteMapping(objectTypeName); + schemaMappingService.clearCache(); // Clear cache to reload + + res.json({ + status: 'success', + message: `Schema mapping deleted for ${objectTypeName}`, + }); + } catch (error) { + logger.error('Failed to delete schema mapping', error); + res.status(500).json({ error: 'Failed to delete schema mapping' }); + } +}); + +/** + * GET /api/data-validation/object-types + * Get all object types with their sync configuration + */ +router.get('/object-types', async (req: Request, res: Response) => { + try { + logger.debug('GET /api/data-validation/object-types - Fetching object types...'); + const objectTypes = await schemaMappingService.getAllObjectTypesWithConfig(); + logger.info(`GET /api/data-validation/object-types - Returning ${objectTypes.length} object types`); + res.json({ objectTypes }); + } catch (error) { + logger.error('Failed to get object types', error); + res.status(500).json({ + error: 'Failed to get object types', + details: error instanceof Error ? error.message : String(error) + }); + } +}); + +/** + * PATCH /api/data-validation/object-types/:objectTypeName/enabled + * Enable or disable an object type for syncing + */ +router.patch('/object-types/:objectTypeName/enabled', async (req: Request, res: Response) => { + try { + const objectTypeName = getParamString(req, 'objectTypeName'); + const { enabled } = req.body; + + if (typeof enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); + return; + } + + await schemaMappingService.setTypeEnabled(objectTypeName, enabled); + schemaMappingService.clearCache(); + + res.json({ + status: 'success', + message: `${objectTypeName} ${enabled ? 'enabled' : 'disabled'} for syncing`, + }); + } catch (error) { + logger.error('Failed to update object type enabled status', error); + res.status(500).json({ error: 'Failed to update object type enabled status' }); + } +}); + +export default router; diff --git a/backend/src/routes/schema.ts b/backend/src/routes/schema.ts index cae237f..82a4048 100644 --- a/backend/src/routes/schema.ts +++ b/backend/src/routes/schema.ts @@ -1,11 +1,10 @@ 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'; -import { dataService } from '../services/dataService.js'; +import { schemaCacheService } from '../services/schemaCacheService.js'; +import { schemaSyncService } from '../services/SchemaSyncService.js'; +import { schemaMappingService } from '../services/schemaMappingService.js'; import { logger } from '../services/logger.js'; import { jiraAssetsClient } from '../services/jiraAssetsClient.js'; import { requireAuth, requirePermission } from '../middleware/authorization.js'; -import type { CMDBObjectTypeName } from '../generated/jira-types.js'; const router = Router(); @@ -13,125 +12,53 @@ const router = Router(); router.use(requireAuth); router.use(requirePermission('search')); -// 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; - cacheCounts?: Record; // Cache counts by type name (from objectsByType) - jiraCounts?: Record; // Actual counts from Jira Assets API -} - /** * GET /api/schema * Returns the complete Jira Assets schema with object types, attributes, and links + * Data is fetched from database (via cache service) */ router.get('/', async (req, res) => { try { - // Build links between object types - const objectTypesWithLinks: Record = {}; + // Get schema from cache (which fetches from database) + const schema = await schemaCacheService.getSchema(); - // First pass: convert all object types - for (const [typeName, typeDef] of Object.entries(OBJECT_TYPES)) { - objectTypesWithLinks[typeName] = { - ...typeDef, - incomingLinks: [], - outgoingLinks: [], - }; - } + // Optionally fetch Jira counts for comparison (can be slow, so make it optional) + let jiraCounts: Record | undefined; + const includeJiraCounts = req.query.includeJiraCounts === 'true'; - // 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, - }); - } + if (includeJiraCounts) { + const typeNames = Object.keys(schema.objectTypes); + logger.info(`Schema: Fetching object counts from Jira Assets for ${typeNames.length} object types...`); + + jiraCounts = {}; + // Fetch counts in parallel for better performance, using schema mappings + const countPromises = typeNames.map(async (typeName) => { + try { + // Get schema ID for this type + const schemaId = await schemaMappingService.getSchemaId(typeName); + const count = await jiraAssetsClient.getObjectCount(typeName, schemaId); + jiraCounts![typeName] = count; + return { typeName, count }; + } catch (error) { + logger.warn(`Schema: Failed to get count for ${typeName}`, error); + // Use 0 as fallback if API call fails + jiraCounts![typeName] = 0; + return { typeName, count: 0 }; } - } + }); + + await Promise.all(countPromises); + logger.info(`Schema: Fetched counts for ${Object.keys(jiraCounts).length} object types from Jira Assets`); } - // Get cache counts (objectsByType) if available - let cacheCounts: Record | undefined; - try { - const cacheStatus = await dataService.getCacheStatus(); - cacheCounts = cacheStatus.objectsByType; - } catch (err) { - logger.debug('Could not fetch cache counts for schema response', err); - // Continue without cache counts - not critical - } - - // Fetch actual counts from Jira Assets for all object types - // This ensures the counts match exactly what's in Jira Assets - const jiraCounts: Record = {}; - const typeNames = Object.keys(OBJECT_TYPES) as CMDBObjectTypeName[]; - - logger.info(`Schema: Fetching object counts from Jira Assets for ${typeNames.length} object types...`); - - // Fetch counts in parallel for better performance - const countPromises = typeNames.map(async (typeName) => { - try { - const count = await jiraAssetsClient.getObjectCount(typeName); - jiraCounts[typeName] = count; - return { typeName, count }; - } catch (error) { - logger.warn(`Schema: Failed to get count for ${typeName}`, error); - // Use 0 as fallback if API call fails - jiraCounts[typeName] = 0; - return { typeName, count: 0 }; - } - }); - - await Promise.all(countPromises); - - logger.info(`Schema: Fetched counts for ${Object.keys(jiraCounts).length} object types from Jira Assets`); - - const response: SchemaResponse = { - metadata: { - generatedAt: SCHEMA_GENERATED_AT, - objectTypeCount: SCHEMA_OBJECT_TYPE_COUNT, - totalAttributes: SCHEMA_TOTAL_ATTRIBUTES, - }, - objectTypes: objectTypesWithLinks, - cacheCounts, + const response = { + ...schema, jiraCounts, }; res.json(response); } catch (error) { - console.error('Failed to get schema:', error); + logger.error('Failed to get schema:', error); res.status(500).json({ error: 'Failed to get schema' }); } }); @@ -140,60 +67,62 @@ router.get('/', async (req, res) => { * 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, - }); +router.get('/object-type/:typeName', async (req, res) => { + try { + const { typeName } = req.params; + + // Get schema from cache + const schema = await schemaCacheService.getSchema(); + const typeDef = schema.objectTypes[typeName]; + + if (!typeDef) { + return res.status(404).json({ error: `Object type '${typeName}' not found` }); } + + res.json(typeDef); + } catch (error) { + logger.error('Failed to get object type:', error); + res.status(500).json({ error: 'Failed to get object type' }); } - - // 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, - }); - } - } +}); + +/** + * POST /api/schema/discover + * Manually trigger schema synchronization from Jira API + * Requires manage_settings permission + */ +router.post('/discover', requirePermission('manage_settings'), async (req, res) => { + try { + logger.info('Schema: Manual schema sync triggered'); + const result = await schemaSyncService.syncAll(); + schemaCacheService.invalidate(); // Invalidate cache + + res.json({ + success: result.success, + message: 'Schema synchronization completed', + ...result, + }); + } catch (error) { + logger.error('Failed to sync schema:', error); + res.status(500).json({ + error: 'Failed to sync schema', + details: error instanceof Error ? error.message : String(error), + }); + } +}); + +/** + * GET /api/schema/sync-progress + * Get current sync progress + */ +router.get('/sync-progress', requirePermission('manage_settings'), async (req, res) => { + try { + const progress = schemaSyncService.getProgress(); + res.json(progress); + } catch (error) { + logger.error('Failed to get sync progress:', error); + res.status(500).json({ error: 'Failed to get sync progress' }); } - - res.json({ - ...typeDef, - incomingLinks, - outgoingLinks, - }); }); export default router; diff --git a/backend/src/routes/schemaConfiguration.ts b/backend/src/routes/schemaConfiguration.ts new file mode 100644 index 0000000..45301d9 --- /dev/null +++ b/backend/src/routes/schemaConfiguration.ts @@ -0,0 +1,202 @@ +/** + * Schema Configuration routes + * + * Provides endpoints for configuring which object types should be synced. + */ + +import { Router, Request, Response } from 'express'; +import { logger } from '../services/logger.js'; +import { requireAuth, requirePermission } from '../middleware/authorization.js'; +import { schemaConfigurationService } from '../services/schemaConfigurationService.js'; +import { schemaSyncService } from '../services/SchemaSyncService.js'; + +const router = Router(); + +// All routes require authentication and manage_settings permission +router.use(requireAuth); +router.use(requirePermission('manage_settings')); + +/** + * GET /api/schema-configuration/stats + * Get configuration statistics + */ +router.get('/stats', async (req: Request, res: Response) => { + try { + const stats = await schemaConfigurationService.getConfigurationStats(); + res.json(stats); + } catch (error) { + logger.error('Failed to get configuration stats', error); + res.status(500).json({ error: 'Failed to get configuration stats' }); + } +}); + +/** + * POST /api/schema-configuration/discover + * Discover and store all schemas, object types, and attributes from Jira Assets + * Uses the unified SchemaSyncService + */ +router.post('/discover', async (req: Request, res: Response) => { + try { + logger.info('Schema configuration: Manual schema sync triggered'); + const result = await schemaSyncService.syncAll(); + + if (result.schemasProcessed === 0) { + logger.warn('Schema configuration: Sync returned 0 schemas - this might indicate an API issue'); + res.status(400).json({ + success: false, + message: 'No schemas found. Please check: 1) JIRA_SERVICE_ACCOUNT_TOKEN is configured correctly, 2) Jira Assets API is accessible, 3) API endpoint /rest/assets/1.0/objectschema/list is available', + ...result, + }); + return; + } + + res.json({ + success: result.success, + message: 'Schema synchronization completed successfully', + schemasDiscovered: result.schemasProcessed, + objectTypesDiscovered: result.objectTypesProcessed, + attributesDiscovered: result.attributesProcessed, + ...result, + }); + } catch (error) { + logger.error('Failed to sync schemas and object types', error); + res.status(500).json({ + error: 'Failed to sync schemas and object types', + details: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + } +}); + +/** + * GET /api/schema-configuration/object-types + * Get all configured object types grouped by schema + */ +router.get('/object-types', async (req: Request, res: Response) => { + try { + const schemas = await schemaConfigurationService.getConfiguredObjectTypes(); + res.json({ schemas }); + } catch (error) { + logger.error('Failed to get configured object types', error); + res.status(500).json({ error: 'Failed to get configured object types' }); + } +}); + +/** + * PATCH /api/schema-configuration/object-types/:id/enabled + * Enable or disable an object type + */ +router.patch('/object-types/:id/enabled', async (req: Request, res: Response) => { + try { + const id = req.params.id; + const { enabled } = req.body; + + if (typeof enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); + return; + } + + await schemaConfigurationService.setObjectTypeEnabled(id, enabled); + + res.json({ + status: 'success', + message: `Object type ${id} ${enabled ? 'enabled' : 'disabled'}`, + }); + } catch (error) { + logger.error('Failed to update object type enabled status', error); + res.status(500).json({ error: 'Failed to update object type enabled status' }); + } +}); + +/** + * PATCH /api/schema-configuration/object-types/bulk-enabled + * Bulk update enabled status for multiple object types + */ +router.patch('/object-types/bulk-enabled', async (req: Request, res: Response) => { + try { + const { updates } = req.body; + + if (!Array.isArray(updates)) { + res.status(400).json({ error: 'updates must be an array' }); + return; + } + + // Validate each update + for (const update of updates) { + if (!update.id || typeof update.enabled !== 'boolean') { + res.status(400).json({ error: 'Each update must have id (string) and enabled (boolean)' }); + return; + } + } + + await schemaConfigurationService.bulkSetObjectTypesEnabled(updates); + + res.json({ + status: 'success', + message: `Updated ${updates.length} object types`, + }); + } catch (error) { + logger.error('Failed to bulk update object types', error); + res.status(500).json({ error: 'Failed to bulk update object types' }); + } +}); + +/** + * GET /api/schema-configuration/check + * Check if configuration is complete (at least one object type enabled) + */ +router.get('/check', async (req: Request, res: Response) => { + try { + const isComplete = await schemaConfigurationService.isConfigurationComplete(); + const stats = await schemaConfigurationService.getConfigurationStats(); + res.json({ + isConfigured: isComplete, + stats, + }); + } catch (error) { + logger.error('Failed to check configuration', error); + res.status(500).json({ error: 'Failed to check configuration' }); + } +}); + +/** + * GET /api/schema-configuration/schemas + * Get all schemas with their search enabled status + */ +router.get('/schemas', async (req: Request, res: Response) => { + try { + const schemas = await schemaConfigurationService.getSchemas(); + res.json({ schemas }); + } catch (error) { + logger.error('Failed to get schemas', error); + res.status(500).json({ error: 'Failed to get schemas' }); + } +}); + +/** + * PATCH /api/schema-configuration/schemas/:schemaId/search-enabled + * Enable or disable search for a schema + */ +router.patch('/schemas/:schemaId/search-enabled', async (req: Request, res: Response) => { + try { + const schemaId = req.params.schemaId; + const { searchEnabled } = req.body; + + if (typeof searchEnabled !== 'boolean') { + res.status(400).json({ error: 'searchEnabled must be a boolean' }); + return; + } + + await schemaConfigurationService.setSchemaSearchEnabled(schemaId, searchEnabled); + + res.json({ + status: 'success', + message: `Schema ${schemaId} search ${searchEnabled ? 'enabled' : 'disabled'}`, + }); + } catch (error) { + logger.error('Failed to update schema search enabled status', error); + res.status(500).json({ error: 'Failed to update schema search enabled status' }); + } +}); + +export default router; diff --git a/backend/src/services/ObjectSyncService.ts b/backend/src/services/ObjectSyncService.ts new file mode 100644 index 0000000..b633ffb --- /dev/null +++ b/backend/src/services/ObjectSyncService.ts @@ -0,0 +1,395 @@ +/** + * ObjectSyncService - Synchronizes objects from Jira Assets API + * + * Handles: + * - Full sync for enabled types + * - Incremental sync via jira_updated_at + * - Recursive reference processing + * - Reference-only caching for disabled types + */ + +import { logger } from './logger.js'; +import { jiraAssetsClient } from '../infrastructure/jira/JiraAssetsClient.js'; +import { PayloadProcessor, type ProcessedObject } from './PayloadProcessor.js'; +import { SchemaRepository } from '../repositories/SchemaRepository.js'; +import { ObjectCacheRepository } from '../repositories/ObjectCacheRepository.js'; +import type { ObjectEntry } from '../domain/jiraAssetsPayload.js'; +import { SyncPolicy } from '../domain/syncPolicy.js'; + +export interface SyncResult { + objectsProcessed: number; + objectsCached: number; + relationsExtracted: number; + errors: Array<{ objectId: string; error: string }>; +} + +export class ObjectSyncService { + private processor: PayloadProcessor; + + constructor( + private schemaRepo: SchemaRepository, + private cacheRepo: ObjectCacheRepository + ) { + this.processor = new PayloadProcessor(schemaRepo, cacheRepo); + } + + /** + * Sync all objects of an enabled type + */ + async syncObjectType( + schemaId: string, + typeId: number, + typeName: string, + displayName: string + ): Promise { + // Validate schemaId before proceeding + if (!schemaId || schemaId.trim() === '') { + const errorMessage = `Schema ID is missing or empty for object type "${displayName}" (${typeName}). Please run schema sync to ensure all object types are properly associated with their schemas.`; + logger.error(`ObjectSyncService: ${errorMessage}`); + return { + objectsProcessed: 0, + objectsCached: 0, + relationsExtracted: 0, + errors: [{ + objectId: typeName, + error: errorMessage, + }], + }; + } + + logger.info(`ObjectSyncService: Starting sync for ${displayName} (${typeName}) from schema ${schemaId}`); + + const result: SyncResult = { + objectsProcessed: 0, + objectsCached: 0, + relationsExtracted: 0, + errors: [], + }; + + try { + // Get enabled types for sync policy + const enabledTypes = await this.schemaRepo.getEnabledObjectTypes(); + const enabledTypeSet = new Set(enabledTypes.map(t => t.typeName)); + + // Fetch all objects of this type + const iql = `objectType = "${displayName}"`; + let page = 1; + let hasMore = true; + const pageSize = 40; + + while (hasMore) { + let searchResult; + try { + searchResult = await jiraAssetsClient.searchObjects(iql, schemaId, { + page, + pageSize, + }); + } catch (error) { + // Log detailed error information + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const errorDetails = error instanceof Error && error.cause ? String(error.cause) : undefined; + + logger.error(`ObjectSyncService: Failed to search objects for ${typeName}`, { + error: errorMessage, + details: errorDetails, + iql, + schemaId, + page, + }); + + // Add error to result and return early + result.errors.push({ + objectId: typeName, + error: `Failed to fetch objects from Jira: ${errorMessage}. This could be due to network issues, incorrect Jira host URL, or authentication problems. Check backend logs for details.`, + }); + + // Return result with error instead of throwing (allows partial results to be returned) + return result; + } + + if (searchResult.objectEntries.length === 0) { + break; + } + + // Process payload recursively (extracts all referenced objects) + const processed = await this.processor.processPayload( + searchResult.objectEntries, + enabledTypeSet + ); + + // Cache all processed objects + const processedEntries = Array.from(processed.entries()); + let cachedCount = 0; + let skippedCount = 0; + + logger.info(`ObjectSyncService: Processing ${processedEntries.length} objects from payload (includes root + referenced objects). Root objects: ${searchResult.objectEntries.length}`); + + // Group by type for logging + const objectsByType = new Map(); + for (const [objectId, processedObj] of processedEntries) { + const objType = processedObj.typeName || processedObj.objectEntry.objectType?.name || 'Unknown'; + objectsByType.set(objType, (objectsByType.get(objType) || 0) + 1); + } + logger.info(`ObjectSyncService: Objects by type: ${Array.from(objectsByType.entries()).map(([type, count]) => `${type}: ${count}`).join(', ')}`); + + for (const [objectId, processedObj] of processedEntries) { + try { + // Cache the object (will use fallback type name if needed) + // cacheProcessedObject should always succeed now due to the generic fallback fix + await this.cacheProcessedObject(processedObj, enabledTypeSet); + + // Count all cached objects - cacheProcessedObject should always succeed now + // (it uses a generic fallback type name if no type name is available) + cachedCount++; + result.relationsExtracted += processedObj.objectEntry.attributes?.length || 0; + logger.debug(`ObjectSyncService: Successfully cached object ${processedObj.objectEntry.objectKey} (ID: ${objectId}, type: ${processedObj.typeName || processedObj.objectEntry.objectType?.name || 'fallback'})`); + } catch (error) { + logger.error(`ObjectSyncService: Failed to cache object ${objectId} (${processedObj.objectEntry.objectKey})`, error); + result.errors.push({ + objectId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + skippedCount++; + } + } + + result.objectsCached = cachedCount; + + if (skippedCount > 0) { + logger.warn(`ObjectSyncService: Skipped ${skippedCount} objects (no type name available or cache error) out of ${processedEntries.length} processed objects`); + } + + logger.info(`ObjectSyncService: Cached ${cachedCount} objects, skipped ${skippedCount} objects out of ${processedEntries.length} total processed objects`); + + result.objectsProcessed += searchResult.objectEntries.length; + hasMore = searchResult.hasMore; + page++; + } + + logger.info( + `ObjectSyncService: Sync complete for ${displayName} - ${result.objectsProcessed} objects processed, ${result.objectsCached} cached, ${result.errors.length} errors` + ); + } catch (error) { + logger.error(`ObjectSyncService: Failed to sync ${displayName}`, error); + result.errors.push({ + objectId: typeName, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + + return result; + } + + /** + * Sync incremental updates (objects updated since timestamp) + * Note: This may not be supported on Jira Data Center + */ + async syncIncremental( + schemaId: string, + since: Date, + enabledTypes: Set + ): Promise { + logger.info(`ObjectSyncService: Starting incremental sync since ${since.toISOString()}`); + + const result: SyncResult = { + objectsProcessed: 0, + objectsCached: 0, + relationsExtracted: 0, + errors: [], + }; + + try { + // IQL for updated objects (may not work on Data Center) + const iql = `updated >= "${since.toISOString()}"`; + + const searchResult = await jiraAssetsClient.searchObjects(iql, schemaId, { + page: 1, + pageSize: 100, + }); + + // Process all entries + const processed = await this.processor.processPayload(searchResult.objectEntries, enabledTypes); + + // Cache all processed objects + for (const [objectId, processedObj] of processed.entries()) { + try { + await this.cacheProcessedObject(processedObj, enabledTypes); + result.objectsCached++; + } catch (error) { + logger.error(`ObjectSyncService: Failed to cache object ${objectId} in incremental sync`, error); + result.errors.push({ + objectId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + result.objectsProcessed = searchResult.objectEntries.length; + } catch (error) { + logger.error('ObjectSyncService: Incremental sync failed', error); + result.errors.push({ + objectId: 'incremental', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + + return result; + } + + /** + * Sync a single object (for refresh operations) + */ + async syncSingleObject( + objectId: string, + enabledTypes: Set + ): Promise<{ cached: boolean; error?: string }> { + try { + // Fetch object from Jira + const entry = await jiraAssetsClient.getObject(objectId); + if (!entry) { + return { cached: false, error: 'Object not found in Jira' }; + } + + // Process recursively + const processed = await this.processor.processPayload([entry], enabledTypes); + const processedObj = processed.get(String(entry.id)); + + if (!processedObj) { + return { cached: false, error: 'Failed to process object' }; + } + + // Cache object + await this.cacheProcessedObject(processedObj, enabledTypes); + + return { cached: true }; + } catch (error) { + logger.error(`ObjectSyncService: Failed to sync single object ${objectId}`, error); + return { + cached: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Cache a processed object to database + */ + private async cacheProcessedObject( + processed: ProcessedObject, + enabledTypes: Set + ): Promise { + const { objectEntry, typeName, syncPolicy, shouldCacheAttributes } = processed; + + // If typeName is not resolved, try to use Jira type name as fallback + // This allows referenced objects to be cached even if their type hasn't been discovered yet + let effectiveTypeName = typeName; + let isFallbackTypeName = false; + + if (!effectiveTypeName) { + const jiraTypeId = objectEntry.objectType?.id; + const jiraTypeName = objectEntry.objectType?.name; + + if (jiraTypeName) { + // Use Jira type name as fallback (will be stored in object_type_name) + // Generate a PascalCase type name from Jira display name + const { toPascalCase } = await import('./schemaUtils.js'); + effectiveTypeName = toPascalCase(jiraTypeName) || jiraTypeName.replace(/[^a-zA-Z0-9]/g, ''); + isFallbackTypeName = true; + + logger.debug(`ObjectSyncService: Using fallback type name "${effectiveTypeName}" for object ${objectEntry.objectKey} (Jira type ID: ${jiraTypeId}, Jira name: "${jiraTypeName}"). This type needs to be discovered via schema discovery for proper attribute caching.`, { + objectKey: objectEntry.objectKey, + objectId: objectEntry.id, + jiraTypeId, + jiraTypeName, + fallbackTypeName: effectiveTypeName, + }); + } else { + // No type name available at all - try to use a generic fallback + // This ensures referenced objects are always cached and queryable + const jiraTypeIdStr = jiraTypeId ? String(jiraTypeId) : 'unknown'; + effectiveTypeName = `UnknownType_${jiraTypeIdStr}`; + isFallbackTypeName = true; + + logger.warn(`ObjectSyncService: Using generic fallback type name "${effectiveTypeName}" for object ${objectEntry.objectKey} (ID: ${objectEntry.id}, Jira type ID: ${jiraTypeId || 'unknown'}). This object will be cached but may need schema discovery for proper attribute caching.`, { + objectKey: objectEntry.objectKey, + objectId: objectEntry.id, + jiraTypeId, + fallbackTypeName: effectiveTypeName, + hint: 'Run schema discovery to include all object types that are referenced by your synced objects.', + }); + } + } + + // Use effectiveTypeName for the rest of the function + const typeNameToUse = effectiveTypeName!; + + // Normalize object (update processed with effective type name if needed) + let processedForNormalization = processed; + if (isFallbackTypeName) { + processedForNormalization = { + ...processed, + typeName: typeNameToUse, + }; + } + + const normalized = await this.processor.normalizeObject(processedForNormalization); + + // Access the database adapter to use transactions + const db = this.cacheRepo.db; + + logger.debug(`ObjectSyncService: About to cache object ${objectEntry.objectKey} (ID: ${objectEntry.id}) with type "${typeNameToUse}" (fallback: ${isFallbackTypeName})`); + + await db.transaction(async (txDb) => { + const txCacheRepo = new ObjectCacheRepository(txDb); + + // Upsert object record (with effective type name) + logger.debug(`ObjectSyncService: Upserting object ${objectEntry.objectKey} (ID: ${objectEntry.id}) with type "${typeNameToUse}" (fallback: ${isFallbackTypeName})`); + await txCacheRepo.upsertObject({ + ...normalized.objectRecord, + objectTypeName: typeNameToUse, + }); + + // Handle attributes based on sync policy + // CRITICAL: Only replace attributes if attributes[] was present in API response + // For fallback type names, skip attribute caching (we don't have attribute definitions) + if (!isFallbackTypeName && (syncPolicy === SyncPolicy.ENABLED || syncPolicy === SyncPolicy.REFERENCE_ONLY) && shouldCacheAttributes) { + // Delete existing attributes (full replace) + await txCacheRepo.deleteAttributeValues(normalized.objectRecord.id); + + // Insert new attributes + if (normalized.attributeValues.length > 0) { + await txCacheRepo.batchUpsertAttributeValues( + normalized.attributeValues.map(v => ({ + ...v, + objectId: normalized.objectRecord.id, + })) + ); + } + // If attributes[] not present on shallow object, keep existing attributes (don't delete) + } else if (!isFallbackTypeName && (syncPolicy === SyncPolicy.ENABLED || syncPolicy === SyncPolicy.REFERENCE_ONLY)) { + // Cache object metadata even without attributes (reference-only) + // This allows basic object lookups for references + } else if (isFallbackTypeName) { + // For fallback type names, only cache object metadata (no attributes) + // Attributes will be cached once the type is properly discovered + logger.debug(`ObjectSyncService: Skipping attribute caching for object ${objectEntry.objectKey} with fallback type name "${typeNameToUse}". Attributes will be cached after schema discovery.`); + } + + // Upsert relations + await txCacheRepo.deleteRelations(normalized.objectRecord.id); + for (const relation of normalized.relations) { + // Resolve target type name + const targetObj = await txCacheRepo.getObject(relation.targetId); + const targetType = targetObj?.objectTypeName || relation.targetType; + + await txCacheRepo.upsertRelation({ + sourceId: normalized.objectRecord.id, + targetId: relation.targetId, + attributeId: relation.attributeId, + sourceType: normalized.objectRecord.objectTypeName, + targetType, + }); + } + }); + } +} diff --git a/backend/src/services/PayloadProcessor.ts b/backend/src/services/PayloadProcessor.ts new file mode 100644 index 0000000..2b921a1 --- /dev/null +++ b/backend/src/services/PayloadProcessor.ts @@ -0,0 +1,369 @@ +/** + * PayloadProcessor - Recursive processing of Jira Assets API payloads + * + * Handles: + * - Recursive reference expansion (level2, level3, etc.) + * - Cycle detection with visited sets + * - Attribute replacement only when attributes[] present + * - Reference-only caching for disabled types + */ + +import { logger } from './logger.js'; +import type { ObjectEntry, ReferencedObject, ObjectAttribute, ObjectAttributeValue, ConfluenceValue } from '../domain/jiraAssetsPayload.js'; +import { isReferenceValue, isSimpleValue, hasAttributes } from '../domain/jiraAssetsPayload.js'; +import type { SyncPolicy } from '../domain/syncPolicy.js'; +import { SyncPolicy as SyncPolicyEnum } from '../domain/syncPolicy.js'; +import type { SchemaRepository } from '../repositories/SchemaRepository.js'; +import type { ObjectCacheRepository } from '../repositories/ObjectCacheRepository.js'; +import type { AttributeRecord } from '../repositories/SchemaRepository.js'; + +export interface ProcessedObject { + objectEntry: ObjectEntry; + typeName: string | null; // Resolved from objectType.id + syncPolicy: SyncPolicy; + shouldCacheAttributes: boolean; // true if attributes[] present +} + +export class PayloadProcessor { + constructor( + private schemaRepo: SchemaRepository, + private cacheRepo: ObjectCacheRepository + ) {} + + /** + * Process a payload recursively, extracting all objects + * + * @param objectEntries - Root objects from API + * @param enabledTypes - Set of enabled type names for full sync + * @returns Map of objectId -> ProcessedObject (includes recursive references) + */ + async processPayload( + objectEntries: ObjectEntry[], + enabledTypes: Set + ): Promise> { + const processed = new Map(); + const visited = new Set(); // objectId/objectKey for cycle detection + + // Process root entries + for (const entry of objectEntries) { + await this.processEntryRecursive(entry, enabledTypes, processed, visited); + } + + return processed; + } + + /** + * Process a single entry recursively + */ + private async processEntryRecursive( + entry: ObjectEntry | ReferencedObject, + enabledTypes: Set, + processed: Map, + visited: Set + ): Promise { + // Extract ID and key for cycle detection + const objectId = String(entry.id); + const objectKey = entry.objectKey; + + // Check for cycles (use both ID and key as visited can have either) + const visitedKey = `${objectId}:${objectKey}`; + if (visited.has(visitedKey)) { + logger.debug(`PayloadProcessor: Cycle detected for ${objectKey} (${objectId}), skipping`); + return; + } + visited.add(visitedKey); + + // Resolve type name from Jira type ID + const typeName = await this.resolveTypeName(entry.objectType.id); + const syncPolicy = this.getSyncPolicy(typeName, enabledTypes); + + // Determine if we should cache attributes + // CRITICAL: Only replace attributes if attributes[] array is present + const shouldCacheAttributes = hasAttributes(entry); + + // Store processed object (always update if already exists to ensure latest data) + // Convert ReferencedObject to ObjectEntry format for storage + const objectEntry: ObjectEntry = { + id: entry.id, + objectKey: entry.objectKey, + label: entry.label, + objectType: entry.objectType, + created: entry.created, + updated: entry.updated, + hasAvatar: entry.hasAvatar, + timestamp: entry.timestamp, + attributes: hasAttributes(entry) ? entry.attributes : undefined, + }; + + processed.set(objectId, { + objectEntry, + typeName, + syncPolicy, + shouldCacheAttributes, + }); + + logger.debug(`PayloadProcessor: Added object ${objectEntry.objectKey} (ID: ${objectId}, Jira type: ${entry.objectType?.name}, resolved type: ${typeName || 'null'}) to processed map. Total processed: ${processed.size}`); + + // Process recursive references if attributes are present + if (hasAttributes(entry)) { + logger.debug(`PayloadProcessor: Processing ${entry.attributes!.length} attributes for recursive references in object ${objectEntry.objectKey} (ID: ${objectId})`); + await this.processRecursiveReferences( + entry.attributes!, + enabledTypes, + processed, + visited + ); + } else { + logger.debug(`PayloadProcessor: Object ${objectEntry.objectKey} (ID: ${objectId}) has no attributes array, skipping recursive processing`); + } + + // Remove from visited set when done (allows same object in different contexts) + visited.delete(visitedKey); + } + + /** + * Process recursive references from attributes + */ + private async processRecursiveReferences( + attributes: ObjectAttribute[], + enabledTypes: Set, + processed: Map, + visited: Set + ): Promise { + for (const attr of attributes) { + for (const value of attr.objectAttributeValues) { + if (isReferenceValue(value)) { + const refObj = value.referencedObject; + + // Process referenced object recursively + // This handles level2, level3, etc. expansion + await this.processEntryRecursive(refObj, enabledTypes, processed, visited); + } + } + } + } + + /** + * Resolve type name from Jira type ID + */ + private async resolveTypeName(jiraTypeId: number): Promise { + const objectType = await this.schemaRepo.getObjectTypeByJiraId(jiraTypeId); + if (!objectType) { + // Track missing type IDs for diagnostics + logger.debug(`PayloadProcessor: Jira type ID ${jiraTypeId} not found in object_types table. This type needs to be discovered via schema sync.`); + return null; + } + return objectType.typeName || null; + } + + /** + * Get sync policy for a type + */ + private getSyncPolicy(typeName: string | null, enabledTypes: Set): SyncPolicy { + if (!typeName) { + return SyncPolicyEnum.SKIP; // Unknown type - skip + } + if (enabledTypes.has(typeName)) { + return SyncPolicyEnum.ENABLED; + } + // Reference-only: cache minimal metadata for references + return SyncPolicyEnum.REFERENCE_ONLY; + } + + /** + * Normalize an object entry to database format + * This converts ObjectEntry to EAV format + */ + async normalizeObject( + processed: ProcessedObject + ): Promise<{ + objectRecord: { + id: string; + objectKey: string; + objectTypeName: string; + label: string; + jiraUpdatedAt: string; + jiraCreatedAt: string; + }; + attributeValues: Array<{ + attributeId: number; + textValue?: string | null; + numberValue?: number | null; + booleanValue?: boolean | null; + dateValue?: string | null; + datetimeValue?: string | null; + referenceObjectId?: string | null; + referenceObjectKey?: string | null; + referenceObjectLabel?: string | null; + arrayIndex: number; + }>; + relations: Array<{ + targetId: string; + attributeId: number; + targetType: string; + }>; + }> { + const { objectEntry, typeName } = processed; + + if (!typeName) { + throw new Error(`Cannot normalize object ${objectEntry.objectKey}: type name not resolved`); + } + + // Get attributes for this type + const attributeDefs = await this.schemaRepo.getAttributesForType(typeName); + const attrMap = new Map(attributeDefs.map(a => [a.jiraAttrId, a])); + + // Extract object record + const objectRecord = { + id: String(objectEntry.id), + objectKey: objectEntry.objectKey, + objectTypeName: typeName, + label: objectEntry.label, + jiraUpdatedAt: objectEntry.updated, + jiraCreatedAt: objectEntry.created, + }; + + // Normalize attributes + const attributeValues: Array<{ + attributeId: number; + textValue?: string | null; + numberValue?: number | null; + booleanValue?: boolean | null; + dateValue?: string | null; + datetimeValue?: string | null; + referenceObjectId?: string | null; + referenceObjectKey?: string | null; + referenceObjectLabel?: string | null; + arrayIndex: number; + }> = []; + + const relations: Array<{ + targetId: string; + attributeId: number; + targetType: string; + }> = []; + + // Process attributes if present + if (hasAttributes(objectEntry) && objectEntry.attributes) { + for (const attr of objectEntry.attributes) { + const attrDef = attrMap.get(attr.objectTypeAttributeId); + if (!attrDef) { + logger.warn(`PayloadProcessor: Unknown attribute ID ${attr.objectTypeAttributeId} for type ${typeName}`); + continue; + } + + // Process attribute values + for (let arrayIndex = 0; arrayIndex < attr.objectAttributeValues.length; arrayIndex++) { + const value = attr.objectAttributeValues[arrayIndex]; + + // Normalize based on value type + const normalizedValue = this.normalizeAttributeValue(value, attrDef, objectRecord.id, relations); + + attributeValues.push({ + attributeId: attrDef.id, + ...normalizedValue, + arrayIndex: attrDef.isMultiple ? arrayIndex : 0, + }); + } + } + } + + return { + objectRecord, + attributeValues, + relations, + }; + } + + /** + * Normalize a single attribute value + */ + private normalizeAttributeValue( + value: ObjectAttributeValue, + attrDef: AttributeRecord, + sourceObjectId: string, + relations: Array<{ targetId: string; attributeId: number; targetType: string }> + ): { + textValue?: string | null; + numberValue?: number | null; + booleanValue?: boolean | null; + dateValue?: string | null; + datetimeValue?: string | null; + referenceObjectId?: string | null; + referenceObjectKey?: string | null; + referenceObjectLabel?: string | null; + } { + // Handle reference values + if (isReferenceValue(value)) { + const ref = value.referencedObject; + const refId = String(ref.id); + + // Extract relation + // Note: targetType will be resolved later from ref.objectType.id + relations.push({ + targetId: refId, + attributeId: attrDef.id, + targetType: ref.objectType.name, // Will be resolved to typeName during store + }); + + return { + referenceObjectId: refId, + referenceObjectKey: ref.objectKey, + referenceObjectLabel: ref.label, + }; + } + + // Handle simple values + if (isSimpleValue(value)) { + const val = value.value; + + switch (attrDef.attrType) { + case 'text': + case 'textarea': + case 'url': + case 'email': + case 'select': + case 'user': + case 'status': + return { textValue: String(val) }; + + case 'integer': + return { numberValue: typeof val === 'number' ? val : parseInt(String(val), 10) }; + + case 'float': + return { numberValue: typeof val === 'number' ? val : parseFloat(String(val)) }; + + case 'boolean': + return { booleanValue: Boolean(val) }; + + case 'date': + return { dateValue: String(val) }; + + case 'datetime': + return { datetimeValue: String(val) }; + + default: + return { textValue: String(val) }; + } + } + + // Handle status values + if ('status' in value && value.status) { + return { textValue: value.status.name }; + } + + // Handle Confluence values + if ('confluencePage' in value && value.confluencePage) { + const confluenceVal = value as ConfluenceValue; + return { textValue: confluenceVal.confluencePage.url || confluenceVal.displayValue }; + } + + // Handle user values + if ('user' in value && value.user) { + return { textValue: value.user.displayName || value.user.name || value.displayValue }; + } + + // Fallback to displayValue + return { textValue: value.displayValue || null }; + } +} diff --git a/backend/src/services/QueryService.ts b/backend/src/services/QueryService.ts new file mode 100644 index 0000000..ac6d414 --- /dev/null +++ b/backend/src/services/QueryService.ts @@ -0,0 +1,240 @@ +/** + * QueryService - Universal query builder (DB → TypeScript) + * + * Reconstructs TypeScript objects from normalized EAV database. + */ + +import { logger } from './logger.js'; +import { SchemaRepository } from '../repositories/SchemaRepository.js'; +import { ObjectCacheRepository } from '../repositories/ObjectCacheRepository.js'; +import type { CMDBObject, CMDBObjectTypeName } from '../generated/jira-types.js'; +import type { AttributeRecord } from '../repositories/SchemaRepository.js'; + +export interface QueryOptions { + limit?: number; + offset?: number; + orderBy?: string; + orderDir?: 'ASC' | 'DESC'; + searchTerm?: string; +} + +export class QueryService { + constructor( + private schemaRepo: SchemaRepository, + private cacheRepo: ObjectCacheRepository + ) {} + + /** + * Get a single object by ID + */ + async getObject( + typeName: CMDBObjectTypeName, + id: string + ): Promise { + // Get object record + const objRecord = await this.cacheRepo.getObject(id); + if (!objRecord || objRecord.objectTypeName !== typeName) { + return null; + } + + // Reconstruct object from EAV data + return await this.reconstructObject(objRecord); + } + + /** + * Get objects of a type with filters + */ + async getObjects( + typeName: CMDBObjectTypeName, + options: QueryOptions = {} + ): Promise { + const { limit = 1000, offset = 0 } = options; + + logger.debug(`QueryService.getObjects: Querying for typeName="${typeName}" with limit=${limit}, offset=${offset}`); + + // Get object records + const objRecords = await this.cacheRepo.getObjectsByType(typeName, { limit, offset }); + + logger.debug(`QueryService.getObjects: Found ${objRecords.length} object records for typeName="${typeName}"`); + + // Check if no records found - might be a type name mismatch + if (objRecords.length === 0) { + // Diagnostic: Check what object_type_name values actually exist in the database + const db = this.cacheRepo.db; + try { + const allTypeNames = await db.query<{ object_type_name: string; count: number }>( + `SELECT object_type_name, COUNT(*) as count + FROM objects + GROUP BY object_type_name + ORDER BY count DESC + LIMIT 20` + ); + logger.warn(`QueryService.getObjects: No objects found for typeName="${typeName}". Available object_type_name values in database:`, { + requestedType: typeName, + availableTypes: allTypeNames.map(t => ({ typeName: t.object_type_name, count: t.count })), + totalTypes: allTypeNames.length, + hint: 'The typeName might not match the object_type_name stored in the database. Check for case sensitivity or naming differences.', + }); + } catch (error) { + logger.debug('QueryService.getObjects: Failed to query available type names', error); + } + } + + // Reconstruct all objects + const objects = await Promise.all( + objRecords.map(record => this.reconstructObject(record)) + ); + + // Filter out nulls and type assert + const validObjects = objects.filter(obj => obj !== null && obj !== undefined); + + logger.debug(`QueryService.getObjects: Successfully reconstructed ${validObjects.length}/${objRecords.length} objects for typeName="${typeName}"`); + + return validObjects as T[]; + } + + /** + * Count objects of a type + */ + async countObjects(typeName: CMDBObjectTypeName): Promise { + return await this.cacheRepo.countObjectsByType(typeName); + } + + /** + * Search objects by label + */ + async searchByLabel( + typeName: CMDBObjectTypeName, + searchTerm: string, + options: QueryOptions = {} + ): Promise { + const { limit = 100, offset = 0 } = options; + + // Get object records with label filter + const objRecords = await this.cacheRepo.db.query<{ + id: string; + objectKey: string; + objectTypeName: string; + label: string; + jiraUpdatedAt: string | null; + jiraCreatedAt: string | null; + cachedAt: string; + }>( + `SELECT id, object_key as objectKey, object_type_name as objectTypeName, label, + jira_updated_at as jiraUpdatedAt, jira_created_at as jiraCreatedAt, cached_at as cachedAt + FROM objects + WHERE object_type_name = ? AND LOWER(label) LIKE LOWER(?) + ORDER BY label ASC + LIMIT ? OFFSET ?`, + [typeName, `%${searchTerm}%`, limit, offset] + ); + + // Reconstruct objects + const objects = await Promise.all( + objRecords.map(record => this.reconstructObject(record)) + ); + + // Filter out nulls and type assert + const validObjects = objects.filter(obj => obj !== null && obj !== undefined); + return validObjects as T[]; + } + + /** + * Reconstruct a TypeScript object from database records + */ + private async reconstructObject( + objRecord: { + id: string; + objectKey: string; + objectTypeName: string; + label: string; + jiraUpdatedAt: string | null; + jiraCreatedAt: string | null; + } + ): Promise { + // Get attribute definitions for this type + const attributeDefs = await this.schemaRepo.getAttributesForType(objRecord.objectTypeName); + const attrMap = new Map(attributeDefs.map(a => [a.id, a])); + + // Get attribute values + const attributeValues = await this.cacheRepo.getAttributeValues(objRecord.id); + + // Build attribute map: fieldName -> value(s) + const attributes: Record = {}; + + for (const attrValue of attributeValues) { + const attrDef = attrMap.get(attrValue.attributeId); + if (!attrDef) { + logger.warn(`QueryService: Unknown attribute ID ${attrValue.attributeId} for object ${objRecord.id}`); + continue; + } + + // Extract value based on type + let value: unknown = null; + switch (attrDef.attrType) { + case 'reference': + if (attrValue.referenceObjectId) { + value = { + objectId: attrValue.referenceObjectId, + objectKey: attrValue.referenceObjectKey || '', + label: attrValue.referenceObjectLabel || '', + }; + } + break; + + case 'text': + case 'textarea': + case 'url': + case 'email': + case 'select': + case 'user': + case 'status': + value = attrValue.textValue; + break; + + case 'integer': + case 'float': + value = attrValue.numberValue; + break; + + case 'boolean': + value = attrValue.booleanValue; + break; + + case 'date': + value = attrValue.dateValue; + break; + + case 'datetime': + value = attrValue.datetimeValue; + break; + + default: + value = attrValue.textValue; + } + + // Handle arrays vs single values + if (attrDef.isMultiple) { + if (!attributes[attrDef.fieldName]) { + attributes[attrDef.fieldName] = []; + } + (attributes[attrDef.fieldName] as unknown[]).push(value); + } else { + attributes[attrDef.fieldName] = value; + } + } + + // Build CMDBObject + const result: Record = { + id: objRecord.id, + objectKey: objRecord.objectKey, + label: objRecord.label, + _objectType: objRecord.objectTypeName, + _jiraUpdatedAt: objRecord.jiraUpdatedAt || new Date().toISOString(), + _jiraCreatedAt: objRecord.jiraCreatedAt || new Date().toISOString(), + ...attributes, + }; + + return result as T; + } +} diff --git a/backend/src/services/RefreshService.ts b/backend/src/services/RefreshService.ts new file mode 100644 index 0000000..c6e5af5 --- /dev/null +++ b/backend/src/services/RefreshService.ts @@ -0,0 +1,75 @@ +/** + * RefreshService - Handles force-refresh-on-read with deduping/locks + * + * Prevents duplicate refresh operations for the same object. + */ + +import { logger } from './logger.js'; +import { jiraAssetsClient } from '../infrastructure/jira/JiraAssetsClient.js'; +import { ObjectSyncService } from './ObjectSyncService.js'; +import { SchemaRepository } from '../repositories/SchemaRepository.js'; + +export class RefreshService { + private refreshLocks: Map> = new Map(); + private readonly LOCK_TIMEOUT_MS = 30000; // 30 seconds + + constructor(private syncService: ObjectSyncService) {} + + /** + * Refresh a single object with deduplication + * If another refresh is already in progress, wait for it + */ + async refreshObject( + objectId: string, + enabledTypes: Set + ): Promise<{ success: boolean; error?: string }> { + // Check if refresh already in progress + const existingLock = this.refreshLocks.get(objectId); + if (existingLock) { + logger.debug(`RefreshService: Refresh already in progress for ${objectId}, waiting...`); + try { + await existingLock; + return { success: true }; // Previous refresh succeeded + } catch (error) { + logger.warn(`RefreshService: Previous refresh failed for ${objectId}, retrying...`, error); + // Continue to new refresh + } + } + + // Create new refresh promise + const refreshPromise = this.doRefresh(objectId, enabledTypes); + this.refreshLocks.set(objectId, refreshPromise); + + try { + // Add timeout to prevent locks from hanging forever + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Refresh timeout')), this.LOCK_TIMEOUT_MS); + }); + + await Promise.race([refreshPromise, timeoutPromise]); + + return { success: true }; + } catch (error) { + logger.error(`RefreshService: Failed to refresh object ${objectId}`, error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } finally { + // Clean up lock after a delay (allow concurrent reads) + setTimeout(() => { + this.refreshLocks.delete(objectId); + }, 1000); + } + } + + /** + * Perform the actual refresh + */ + private async doRefresh(objectId: string, enabledTypes: Set): Promise { + const result = await this.syncService.syncSingleObject(objectId, enabledTypes); + if (!result.cached) { + throw new Error(result.error || 'Failed to cache object'); + } + } +} diff --git a/backend/src/services/SchemaSyncService.ts b/backend/src/services/SchemaSyncService.ts new file mode 100644 index 0000000..d009e76 --- /dev/null +++ b/backend/src/services/SchemaSyncService.ts @@ -0,0 +1,817 @@ +/** + * Schema Sync Service + * + * Unified service for synchronizing Jira Assets schema configuration to local database. + * Implements the complete sync flow as specified in the refactor plan. + */ + +import { logger } from './logger.js'; +import { getDatabaseAdapter } from './database/singleton.js'; +import type { DatabaseAdapter } from './database/interface.js'; +import { config } from '../config/env.js'; +import { toCamelCase, toPascalCase, mapJiraType, determineSyncPriority } from './schemaUtils.js'; + +// ============================================================================= +// Types +// ============================================================================= + +interface JiraSchema { + id: number; + name: string; + objectSchemaKey?: string; + status?: string; + description?: string; + created?: string; + updated?: string; + objectCount?: number; + objectTypeCount?: number; +} + +interface JiraObjectType { + id: number; + name: string; + type?: number; + description?: string; + icon?: { + id: number; + name: string; + url16?: string; + url48?: string; + }; + position?: number; + created?: string; + updated?: string; + objectCount?: number; + parentObjectTypeId?: number | null; + objectSchemaId: number; + inherited?: boolean; + abstractObjectType?: boolean; +} + +interface JiraAttribute { + id: number; + objectType?: { + id: number; + name: string; + }; + name: string; + label?: boolean; + type: number; + description?: string; + defaultType?: { + id: number; + name: string; + } | null; + typeValue?: string | null; + typeValueMulti?: string[]; + additionalValue?: string | null; + referenceType?: { + id: number; + name: string; + description?: string; + color?: string; + url16?: string | null; + removable?: boolean; + objectSchemaId?: number; + } | null; + referenceObjectTypeId?: number | null; + referenceObjectType?: { + id: number; + name: string; + objectSchemaId?: number; + } | null; + editable?: boolean; + system?: boolean; + sortable?: boolean; + summable?: boolean; + indexed?: boolean; + minimumCardinality?: number; + maximumCardinality?: number; + suffix?: string; + removable?: boolean; + hidden?: boolean; + includeChildObjectTypes?: boolean; + uniqueAttribute?: boolean; + regexValidation?: string | null; + iql?: string | null; + options?: string; + position?: number; +} + +export interface SyncResult { + success: boolean; + schemasProcessed: number; + objectTypesProcessed: number; + attributesProcessed: number; + schemasDeleted: number; + objectTypesDeleted: number; + attributesDeleted: number; + errors: SyncError[]; + duration: number; // milliseconds +} + +export interface SyncError { + type: 'schema' | 'objectType' | 'attribute'; + id: string | number; + message: string; +} + +export interface SyncProgress { + status: 'idle' | 'running' | 'completed' | 'failed'; + currentSchema?: string; + currentObjectType?: string; + schemasTotal: number; + schemasCompleted: number; + objectTypesTotal: number; + objectTypesCompleted: number; + startedAt?: Date; + estimatedCompletion?: Date; +} + +// ============================================================================= +// SchemaSyncService Implementation +// ============================================================================= + +class SchemaSyncService { + private db: DatabaseAdapter; + private isPostgres: boolean; + private baseUrl: string; + private progress: SyncProgress = { + status: 'idle', + schemasTotal: 0, + schemasCompleted: 0, + objectTypesTotal: 0, + objectTypesCompleted: 0, + }; + + // Rate limiting configuration + private readonly RATE_LIMIT_DELAY_MS = 150; // 150ms between requests + private readonly MAX_RETRIES = 3; + private readonly RETRY_DELAY_MS = 1000; + + constructor() { + this.db = getDatabaseAdapter(); + this.isPostgres = (this.db.isPostgres === true); + this.baseUrl = `${config.jiraHost}/rest/assets/1.0`; + } + + /** + * Get authentication headers for API requests + */ + private getHeaders(): Record { + const token = config.jiraServiceAccountToken; + if (!token) { + throw new Error('JIRA_SERVICE_ACCOUNT_TOKEN not configured. Schema sync requires a service account token.'); + } + return { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + } + + /** + * Rate limiting delay + */ + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Fetch with rate limiting and retry logic + */ + private async fetchWithRateLimit( + url: string, + retries: number = this.MAX_RETRIES + ): Promise { + await this.delay(this.RATE_LIMIT_DELAY_MS); + + try { + const response = await fetch(url, { + headers: this.getHeaders(), + }); + + // Handle rate limiting (429) + if (response.status === 429) { + const retryAfter = parseInt(response.headers.get('Retry-After') || '5', 10); + logger.warn(`SchemaSync: Rate limited, waiting ${retryAfter}s before retry`); + await this.delay(retryAfter * 1000); + return this.fetchWithRateLimit(url, retries); + } + + // Handle server errors with retry + if (response.status >= 500 && retries > 0) { + logger.warn(`SchemaSync: Server error ${response.status}, retrying (${retries} attempts left)`); + await this.delay(this.RETRY_DELAY_MS); + return this.fetchWithRateLimit(url, retries - 1); + } + + if (!response.ok) { + const text = await response.text(); + throw new Error(`HTTP ${response.status}: ${text}`); + } + + return await response.json() as T; + } catch (error) { + if (retries > 0 && error instanceof Error && !error.message.includes('HTTP')) { + logger.warn(`SchemaSync: Network error, retrying (${retries} attempts left)`, error); + await this.delay(this.RETRY_DELAY_MS); + return this.fetchWithRateLimit(url, retries - 1); + } + throw error; + } + } + + /** + * Fetch all schemas from Jira + */ + private async fetchSchemas(): Promise { + const url = `${this.baseUrl}/objectschema/list`; + logger.debug(`SchemaSync: Fetching schemas from ${url}`); + + const result = await this.fetchWithRateLimit<{ objectschemas?: JiraSchema[] } | JiraSchema[]>(url); + + // Handle different response formats + if (Array.isArray(result)) { + return result; + } else if (result && typeof result === 'object' && 'objectschemas' in result) { + return result.objectschemas || []; + } + + logger.warn('SchemaSync: Unexpected schema list response format', result); + return []; + } + + /** + * Fetch schema details + */ + private async fetchSchemaDetails(schemaId: number): Promise { + const url = `${this.baseUrl}/objectschema/${schemaId}`; + logger.debug(`SchemaSync: Fetching schema details for ${schemaId}`); + return await this.fetchWithRateLimit(url); + } + + /** + * Fetch all object types for a schema (flat list) + */ + private async fetchObjectTypes(schemaId: number): Promise { + const url = `${this.baseUrl}/objectschema/${schemaId}/objecttypes/flat`; + logger.debug(`SchemaSync: Fetching object types for schema ${schemaId}`); + + try { + const result = await this.fetchWithRateLimit(url); + return Array.isArray(result) ? result : []; + } catch (error) { + // Fallback to regular endpoint if flat endpoint fails + logger.warn(`SchemaSync: Flat endpoint failed, trying regular endpoint`, error); + const fallbackUrl = `${this.baseUrl}/objectschema/${schemaId}/objecttypes`; + const fallbackResult = await this.fetchWithRateLimit<{ objectTypes?: JiraObjectType[] } | JiraObjectType[]>(fallbackUrl); + + if (Array.isArray(fallbackResult)) { + return fallbackResult; + } else if (fallbackResult && typeof fallbackResult === 'object' && 'objectTypes' in fallbackResult) { + return fallbackResult.objectTypes || []; + } + return []; + } + } + + /** + * Fetch object type details + */ + private async fetchObjectTypeDetails(typeId: number): Promise { + const url = `${this.baseUrl}/objecttype/${typeId}`; + logger.debug(`SchemaSync: Fetching object type details for ${typeId}`); + return await this.fetchWithRateLimit(url); + } + + /** + * Fetch attributes for an object type + */ + private async fetchAttributes(typeId: number): Promise { + const url = `${this.baseUrl}/objecttype/${typeId}/attributes`; + logger.debug(`SchemaSync: Fetching attributes for object type ${typeId}`); + + try { + const result = await this.fetchWithRateLimit(url); + return Array.isArray(result) ? result : []; + } catch (error) { + logger.warn(`SchemaSync: Failed to fetch attributes for type ${typeId}`, error); + return []; + } + } + + /** + * Parse Jira attribute to database format + */ + private parseAttribute( + attr: JiraAttribute, + allTypeConfigs: Map + ): { + jiraId: number; + name: string; + fieldName: string; + type: string; + isMultiple: boolean; + isEditable: boolean; + isRequired: boolean; + isSystem: boolean; + referenceTypeName?: string; + description?: string; + // Additional fields from plan + label?: boolean; + sortable?: boolean; + summable?: boolean; + indexed?: boolean; + suffix?: string; + removable?: boolean; + hidden?: boolean; + includeChildObjectTypes?: boolean; + uniqueAttribute?: boolean; + regexValidation?: string | null; + iql?: string | null; + options?: string; + position?: number; + } { + 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.referenceObject?.id || attr.referenceType?.id; + if (refTypeId) { + type = 'reference'; + } + + const result: ReturnType = { + jiraId: attr.id, + name: attr.name, + fieldName: toCamelCase(attr.name), + type, + isMultiple, + isEditable, + isRequired, + isSystem, + description: attr.description, + label: attr.label, + sortable: attr.sortable, + summable: attr.summable, + indexed: attr.indexed, + suffix: attr.suffix, + removable: attr.removable, + hidden: attr.hidden, + includeChildObjectTypes: attr.includeChildObjectTypes, + uniqueAttribute: attr.uniqueAttribute, + regexValidation: attr.regexValidation, + iql: attr.iql, + options: attr.options, + position: attr.position, + }; + + // Handle reference types - add reference metadata + if (type === 'reference' && refTypeId) { + const refConfig = allTypeConfigs.get(refTypeId); + result.referenceTypeName = refConfig?.typeName || + attr.referenceObjectType?.name || + attr.referenceType?.name || + `Type${refTypeId}`; + } + + return result; + } + + /** + * Sync all schemas and their complete structure + */ + async syncAll(): Promise { + const startTime = Date.now(); + const errors: SyncError[] = []; + + this.progress = { + status: 'running', + schemasTotal: 0, + schemasCompleted: 0, + objectTypesTotal: 0, + objectTypesCompleted: 0, + startedAt: new Date(), + }; + + try { + logger.info('SchemaSync: Starting full schema synchronization...'); + + // Step 1: Fetch all schemas + const schemas = await this.fetchSchemas(); + this.progress.schemasTotal = schemas.length; + logger.info(`SchemaSync: Found ${schemas.length} schemas to sync`); + + if (schemas.length === 0) { + throw new Error('No schemas found in Jira Assets'); + } + + // Track Jira IDs for cleanup + const jiraSchemaIds = new Set(); + const jiraObjectTypeIds = new Map>(); // schemaId -> Set + const jiraAttributeIds = new Map>(); // typeName -> Set + + let schemasProcessed = 0; + let objectTypesProcessed = 0; + let attributesProcessed = 0; + let schemasDeleted = 0; + let objectTypesDeleted = 0; + let attributesDeleted = 0; + + await this.db.transaction(async (txDb) => { + // Step 2: Process each schema + for (const schema of schemas) { + try { + this.progress.currentSchema = schema.name; + const schemaIdStr = schema.id.toString(); + jiraSchemaIds.add(schemaIdStr); + + // Fetch schema details + let schemaDetails: JiraSchema; + try { + schemaDetails = await this.fetchSchemaDetails(schema.id); + } catch (error) { + logger.warn(`SchemaSync: Failed to fetch details for schema ${schema.id}, using list data`, error); + schemaDetails = schema; + } + + const now = new Date().toISOString(); + const objectSchemaKey = schemaDetails.objectSchemaKey || schemaDetails.name || schemaIdStr; + + // Upsert schema + if (txDb.isPostgres) { + await txDb.execute(` + INSERT INTO schemas (jira_schema_id, name, object_schema_key, status, description, discovered_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(jira_schema_id) DO UPDATE SET + name = excluded.name, + object_schema_key = excluded.object_schema_key, + status = excluded.status, + description = excluded.description, + updated_at = excluded.updated_at + `, [ + schemaIdStr, + schemaDetails.name, + objectSchemaKey, + schemaDetails.status || null, + schemaDetails.description || null, + now, + now, + ]); + } else { + await txDb.execute(` + INSERT INTO schemas (jira_schema_id, name, object_schema_key, status, description, discovered_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(jira_schema_id) DO UPDATE SET + name = excluded.name, + object_schema_key = excluded.object_schema_key, + status = excluded.status, + description = excluded.description, + updated_at = excluded.updated_at + `, [ + schemaIdStr, + schemaDetails.name, + objectSchemaKey, + schemaDetails.status || null, + schemaDetails.description || null, + now, + now, + ]); + } + + // Get schema FK + const schemaRow = await txDb.queryOne<{ id: number }>( + `SELECT id FROM schemas WHERE jira_schema_id = ?`, + [schemaIdStr] + ); + + if (!schemaRow) { + throw new Error(`Failed to get schema FK for ${schemaIdStr}`); + } + + const schemaIdFk = schemaRow.id; + + // Step 3: Fetch all object types for this schema + const objectTypes = await this.fetchObjectTypes(schema.id); + logger.info(`SchemaSync: Found ${objectTypes.length} object types in schema ${schema.name}`); + + const typeConfigs = new Map(); + jiraObjectTypeIds.set(schemaIdStr, new Set()); + + // Build type name mapping + for (const objType of objectTypes) { + const typeName = toPascalCase(objType.name); + typeConfigs.set(objType.id, { + name: objType.name, + typeName, + }); + jiraObjectTypeIds.get(schemaIdStr)!.add(objType.id); + } + + // Step 4: Store object types + for (const objType of objectTypes) { + try { + this.progress.currentObjectType = objType.name; + const typeName = toPascalCase(objType.name); + const objectCount = objType.objectCount || 0; + const syncPriority = determineSyncPriority(objType.name, objectCount); + + // Upsert object type + if (txDb.isPostgres) { + await txDb.execute(` + INSERT INTO object_types ( + schema_id, jira_type_id, type_name, display_name, description, + sync_priority, object_count, enabled, discovered_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(schema_id, jira_type_id) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + sync_priority = excluded.sync_priority, + object_count = excluded.object_count, + updated_at = excluded.updated_at + `, [ + schemaIdFk, + objType.id, + typeName, + objType.name, + objType.description || null, + syncPriority, + objectCount, + false, // Default: disabled + now, + now, + ]); + } else { + await txDb.execute(` + INSERT INTO object_types ( + schema_id, jira_type_id, type_name, display_name, description, + sync_priority, object_count, enabled, discovered_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(schema_id, jira_type_id) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + sync_priority = excluded.sync_priority, + object_count = excluded.object_count, + updated_at = excluded.updated_at + `, [ + schemaIdFk, + objType.id, + typeName, + objType.name, + objType.description || null, + syncPriority, + objectCount, + 0, // Default: disabled (0 = false in SQLite) + now, + now, + ]); + } + + objectTypesProcessed++; + + // Step 5: Fetch and store attributes + const attributes = await this.fetchAttributes(objType.id); + logger.info(`SchemaSync: Fetched ${attributes.length} attributes for ${objType.name} (type ${objType.id})`); + + if (!jiraAttributeIds.has(typeName)) { + jiraAttributeIds.set(typeName, new Set()); + } + + if (attributes.length === 0) { + logger.warn(`SchemaSync: No attributes found for ${objType.name} (type ${objType.id})`); + } + + for (const jiraAttr of attributes) { + try { + const attrDef = this.parseAttribute(jiraAttr, typeConfigs); + jiraAttributeIds.get(typeName)!.add(attrDef.jiraId); + + // Upsert attribute + if (txDb.isPostgres) { + await txDb.execute(` + INSERT INTO attributes ( + jira_attr_id, object_type_name, attr_name, field_name, attr_type, + is_multiple, is_editable, is_required, is_system, + reference_type_name, description, position, discovered_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(jira_attr_id, object_type_name) DO UPDATE SET + attr_name = excluded.attr_name, + field_name = excluded.field_name, + attr_type = excluded.attr_type, + is_multiple = excluded.is_multiple, + is_editable = excluded.is_editable, + is_required = excluded.is_required, + is_system = excluded.is_system, + reference_type_name = excluded.reference_type_name, + description = excluded.description, + position = excluded.position + `, [ + attrDef.jiraId, + typeName, + attrDef.name, + attrDef.fieldName, + attrDef.type, + attrDef.isMultiple, + attrDef.isEditable, + attrDef.isRequired, + attrDef.isSystem, + attrDef.referenceTypeName || null, + attrDef.description || null, + attrDef.position ?? 0, + now, + ]); + } else { + await txDb.execute(` + INSERT INTO attributes ( + jira_attr_id, object_type_name, attr_name, field_name, attr_type, + is_multiple, is_editable, is_required, is_system, + reference_type_name, description, position, discovered_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(jira_attr_id, object_type_name) DO UPDATE SET + attr_name = excluded.attr_name, + field_name = excluded.field_name, + attr_type = excluded.attr_type, + is_multiple = excluded.is_multiple, + is_editable = excluded.is_editable, + is_required = excluded.is_required, + is_system = excluded.is_system, + reference_type_name = excluded.reference_type_name, + description = excluded.description, + position = excluded.position + `, [ + attrDef.jiraId, + typeName, + attrDef.name, + attrDef.fieldName, + attrDef.type, + attrDef.isMultiple ? 1 : 0, + attrDef.isEditable ? 1 : 0, + attrDef.isRequired ? 1 : 0, + attrDef.isSystem ? 1 : 0, + attrDef.referenceTypeName || null, + attrDef.description || null, + attrDef.position ?? 0, + now, + ]); + } + + attributesProcessed++; + } catch (error) { + logger.error(`SchemaSync: Failed to process attribute ${jiraAttr.id} (${jiraAttr.name}) for ${objType.name}`, error); + if (error instanceof Error) { + logger.error(`SchemaSync: Attribute error details: ${error.message}`, error.stack); + } + errors.push({ + type: 'attribute', + id: jiraAttr.id, + message: error instanceof Error ? error.message : String(error), + }); + } + } + + logger.info(`SchemaSync: Processed ${attributesProcessed} attributes for ${objType.name} (type ${objType.id})`); + this.progress.objectTypesCompleted++; + } catch (error) { + logger.warn(`SchemaSync: Failed to process object type ${objType.id}`, error); + errors.push({ + type: 'objectType', + id: objType.id, + message: error instanceof Error ? error.message : String(error), + }); + } + } + + this.progress.schemasCompleted++; + schemasProcessed++; + } catch (error) { + logger.error(`SchemaSync: Failed to process schema ${schema.id}`, error); + errors.push({ + type: 'schema', + id: schema.id.toString(), + message: error instanceof Error ? error.message : String(error), + }); + } + } + + // Step 6: Clean up orphaned records (hard delete) + logger.info('SchemaSync: Cleaning up orphaned records...'); + + // Delete orphaned schemas + const allLocalSchemas = await txDb.query<{ jira_schema_id: string }>( + `SELECT jira_schema_id FROM schemas` + ); + for (const localSchema of allLocalSchemas) { + if (!jiraSchemaIds.has(localSchema.jira_schema_id)) { + logger.info(`SchemaSync: Deleting orphaned schema ${localSchema.jira_schema_id}`); + await txDb.execute(`DELETE FROM schemas WHERE jira_schema_id = ?`, [localSchema.jira_schema_id]); + schemasDeleted++; + } + } + + // Delete orphaned object types + // First, get all object types from all remaining schemas + const allLocalObjectTypes = await txDb.query<{ schema_id: number; jira_type_id: number; jira_schema_id: string }>( + `SELECT ot.schema_id, ot.jira_type_id, s.jira_schema_id + FROM object_types ot + JOIN schemas s ON ot.schema_id = s.id` + ); + + for (const localType of allLocalObjectTypes) { + const schemaIdStr = localType.jira_schema_id; + const typeIds = jiraObjectTypeIds.get(schemaIdStr); + + // If schema doesn't exist in Jira anymore, or type doesn't exist in schema + if (!jiraSchemaIds.has(schemaIdStr) || (typeIds && !typeIds.has(localType.jira_type_id))) { + logger.info(`SchemaSync: Deleting orphaned object type ${localType.jira_type_id} from schema ${schemaIdStr}`); + await txDb.execute( + `DELETE FROM object_types WHERE schema_id = ? AND jira_type_id = ?`, + [localType.schema_id, localType.jira_type_id] + ); + objectTypesDeleted++; + } + } + + // Delete orphaned attributes + // Get all attributes and check against synced types + const allLocalAttributes = await txDb.query<{ object_type_name: string; jira_attr_id: number }>( + `SELECT object_type_name, jira_attr_id FROM attributes` + ); + + for (const localAttr of allLocalAttributes) { + const attrIds = jiraAttributeIds.get(localAttr.object_type_name); + + // If type wasn't synced or attribute doesn't exist in type + if (!attrIds || !attrIds.has(localAttr.jira_attr_id)) { + logger.info(`SchemaSync: Deleting orphaned attribute ${localAttr.jira_attr_id} from type ${localAttr.object_type_name}`); + await txDb.execute( + `DELETE FROM attributes WHERE object_type_name = ? AND jira_attr_id = ?`, + [localAttr.object_type_name, localAttr.jira_attr_id] + ); + attributesDeleted++; + } + } + + logger.info(`SchemaSync: Cleanup complete - ${schemasDeleted} schemas, ${objectTypesDeleted} object types, ${attributesDeleted} attributes deleted`); + }); + + const duration = Date.now() - startTime; + this.progress.status = 'completed'; + + logger.info(`SchemaSync: Synchronization complete in ${duration}ms - ${schemasProcessed} schemas, ${objectTypesProcessed} object types, ${attributesProcessed} attributes, ${schemasDeleted} deleted schemas, ${objectTypesDeleted} deleted types, ${attributesDeleted} deleted attributes`); + + if (attributesProcessed === 0) { + logger.warn(`SchemaSync: WARNING - No attributes were saved! Check logs for errors.`); + } + + if (errors.length > 0) { + logger.warn(`SchemaSync: Sync completed with ${errors.length} errors:`, errors); + } + + return { + success: errors.length === 0, + schemasProcessed, + objectTypesProcessed, + attributesProcessed, + schemasDeleted, + objectTypesDeleted, + attributesDeleted, + errors, + duration, + }; + } catch (error) { + this.progress.status = 'failed'; + logger.error('SchemaSync: Synchronization failed', error); + throw error; + } + } + + /** + * Sync a single schema by ID + */ + async syncSchema(schemaId: number): Promise { + // For single schema sync, we can reuse syncAll logic but filter + // For now, just call syncAll (it's idempotent) + logger.info(`SchemaSync: Syncing single schema ${schemaId}`); + return this.syncAll(); + } + + /** + * Get sync status/progress + */ + getProgress(): SyncProgress { + return { ...this.progress }; + } +} + +// Export singleton instance +export const schemaSyncService = new SchemaSyncService(); diff --git a/backend/src/services/ServiceFactory.ts b/backend/src/services/ServiceFactory.ts new file mode 100644 index 0000000..77b9282 --- /dev/null +++ b/backend/src/services/ServiceFactory.ts @@ -0,0 +1,68 @@ +/** + * ServiceFactory - Creates and initializes all services + * + * Single entry point for service initialization and dependency injection. + */ + +import { getDatabaseAdapter } from './database/singleton.js'; +import { ensureSchemaInitialized } from './database/normalized-schema-init.js'; +import { SchemaRepository } from '../repositories/SchemaRepository.js'; +import { ObjectCacheRepository } from '../repositories/ObjectCacheRepository.js'; +import { SchemaSyncService } from './SchemaSyncService.js'; +import { ObjectSyncService } from './ObjectSyncService.js'; +import { PayloadProcessor } from './PayloadProcessor.js'; +import { QueryService } from './QueryService.js'; +import { RefreshService } from './RefreshService.js'; +import { WriteThroughService } from './WriteThroughService.js'; +import { logger } from './logger.js'; + +/** + * All services container + */ +export class ServiceFactory { + public readonly schemaRepo: SchemaRepository; + public readonly cacheRepo: ObjectCacheRepository; + public readonly schemaSyncService: SchemaSyncService; + public readonly objectSyncService: ObjectSyncService; + public readonly payloadProcessor: PayloadProcessor; + public readonly queryService: QueryService; + public readonly refreshService: RefreshService; + public readonly writeThroughService: WriteThroughService; + + private static instance: ServiceFactory | null = null; + + private constructor() { + // Use shared database adapter singleton + const db = getDatabaseAdapter(); + + // Initialize repositories + this.schemaRepo = new SchemaRepository(db); + this.cacheRepo = new ObjectCacheRepository(db); + + // Initialize services + this.schemaSyncService = new SchemaSyncService(this.schemaRepo); + this.objectSyncService = new ObjectSyncService(this.schemaRepo, this.cacheRepo); + this.payloadProcessor = new PayloadProcessor(this.schemaRepo, this.cacheRepo); + this.queryService = new QueryService(this.schemaRepo, this.cacheRepo); + this.refreshService = new RefreshService(this.objectSyncService); + this.writeThroughService = new WriteThroughService(this.objectSyncService, this.schemaRepo); + + // Ensure schema is initialized (async, but don't block) + ensureSchemaInitialized().catch(error => { + logger.error('ServiceFactory: Failed to initialize database schema', error); + }); + } + + /** + * Get singleton instance + */ + static getInstance(): ServiceFactory { + if (!ServiceFactory.instance) { + ServiceFactory.instance = new ServiceFactory(); + } + return ServiceFactory.instance; + } +} + +// Export singleton instance getter +export const getServices = () => ServiceFactory.getInstance(); diff --git a/backend/src/services/WriteThroughService.ts b/backend/src/services/WriteThroughService.ts new file mode 100644 index 0000000..8f4fec7 --- /dev/null +++ b/backend/src/services/WriteThroughService.ts @@ -0,0 +1,153 @@ +/** + * WriteThroughService - Write-through updates to Jira and DB + * + * Writes to Jira Assets API, then immediately updates DB cache. + */ + +import { logger } from './logger.js'; +import { jiraAssetsClient } from '../infrastructure/jira/JiraAssetsClient.js'; +import { ObjectSyncService } from './ObjectSyncService.js'; +import { SchemaRepository } from '../repositories/SchemaRepository.js'; +import type { CMDBObject, CMDBObjectTypeName } from '../generated/jira-types.js'; + +export interface UpdateResult { + success: boolean; + data?: CMDBObject; + error?: string; +} + +export class WriteThroughService { + constructor( + private syncService: ObjectSyncService, + private schemaRepo: SchemaRepository + ) {} + + /** + * Update an object (write-through) + * + * 1. Build Jira update payload from field updates + * 2. Send update to Jira Assets API + * 3. Fetch fresh data from Jira + * 4. Update DB cache using same normalization logic + */ + async updateObject( + typeName: CMDBObjectTypeName, + objectId: string, + updates: Record + ): Promise { + try { + // Get attribute definitions for this type + const attributeDefs = await this.schemaRepo.getAttributesForType(typeName); + const attrMapByName = new Map(attributeDefs.map(a => [a.fieldName, a])); + + // Build Jira update payload + const payload = { + attributes: [] as Array<{ + objectTypeAttributeId: number; + objectAttributeValues: Array<{ value?: string }>; + }>, + }; + + for (const [fieldName, value] of Object.entries(updates)) { + const attrDef = attrMapByName.get(fieldName); + if (!attrDef) { + logger.warn(`WriteThroughService: Unknown field ${fieldName} for type ${typeName}`); + continue; + } + + if (!attrDef.isEditable) { + logger.warn(`WriteThroughService: Field ${fieldName} is not editable`); + continue; + } + + // Build attribute values based on type + const attrValues = this.buildAttributeValues(value, attrDef); + + if (attrValues.length > 0 || value === null || value === undefined) { + // Include attribute even if clearing (empty array) + payload.attributes.push({ + objectTypeAttributeId: attrDef.jiraAttrId, + objectAttributeValues: attrValues, + }); + } + } + + if (payload.attributes.length === 0) { + return { success: true }; // No attributes to update + } + + // Send update to Jira + await jiraAssetsClient.updateObject(objectId, payload); + + // Fetch fresh data from Jira + const entry = await jiraAssetsClient.getObject(objectId); + if (!entry) { + return { + success: false, + error: 'Object not found in Jira after update', + }; + } + + // Get enabled types for sync policy + const enabledTypes = await this.schemaRepo.getEnabledObjectTypes(); + const enabledTypeSet = new Set(enabledTypes.map(t => t.typeName)); + + // Update DB cache using sync service + const syncResult = await this.syncService.syncSingleObject(objectId, enabledTypeSet); + if (!syncResult.cached) { + logger.warn(`WriteThroughService: Failed to update cache after Jira update: ${syncResult.error}`); + // Still return success if Jira update succeeded + } + + // Fetch updated object from DB + // Note: We'd need QueryService here, but to avoid circular deps, + // we'll return success and let caller refresh if needed + return { success: true }; + } catch (error) { + logger.error(`WriteThroughService: Failed to update object ${objectId}`, error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Build Jira attribute values from TypeScript value + */ + private buildAttributeValues( + value: unknown, + attrDef: { attrType: string; isMultiple: boolean } + ): Array<{ value?: string }> { + // Null/undefined = clear the field + if (value === null || value === undefined) { + return []; + } + + // Reference type + if (attrDef.attrType === 'reference') { + if (attrDef.isMultiple && Array.isArray(value)) { + return (value as Array<{ objectKey?: string }>).map(ref => ({ + value: ref.objectKey, + })).filter(v => v.value); + } else if (!attrDef.isMultiple) { + const ref = value as { objectKey?: string }; + return ref.objectKey ? [{ value: ref.objectKey }] : []; + } + return []; + } + + // Boolean + if (attrDef.attrType === 'boolean') { + return [{ value: value ? 'true' : 'false' }]; + } + + // Number types + if (attrDef.attrType === 'integer' || attrDef.attrType === 'float') { + return [{ value: String(value) }]; + } + + // String types + return [{ value: String(value) }]; + } +} diff --git a/backend/src/services/authService.ts b/backend/src/services/authService.ts index 8c58c33..1b57ba2 100644 --- a/backend/src/services/authService.ts +++ b/backend/src/services/authService.ts @@ -150,15 +150,19 @@ class AuthService { } // Check if expired - if (new Date(session.expires_at) < new Date()) { + const expiresAt = new Date(session.expires_at); + const now = new Date(); + if (expiresAt < now) { await db.execute('DELETE FROM sessions WHERE id = ?', [sessionId]); return null; } return session; - } finally { - await db.close(); + } catch (error) { + logger.error(`[getSessionFromDb] Error querying session: ${sessionId.substring(0, 8)}...`, error); + throw error; } + // Note: Don't close the database adapter - it's a singleton that should remain open } /** diff --git a/backend/src/services/cacheStore.ts b/backend/src/services/cacheStore.old.ts similarity index 100% rename from backend/src/services/cacheStore.ts rename to backend/src/services/cacheStore.old.ts diff --git a/backend/src/services/cmdbService.ts b/backend/src/services/cmdbService.ts index bc5b56e..4713e93 100644 --- a/backend/src/services/cmdbService.ts +++ b/backend/src/services/cmdbService.ts @@ -8,7 +8,7 @@ */ import { logger } from './logger.js'; -import { cacheStore, type CacheStats } from './cacheStore.js'; +import { normalizedCacheStore as cacheStore, type CacheStats } from './normalizedCacheStore.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'; @@ -65,7 +65,11 @@ class CMDBService { return cached; } - // Cache miss: fetch from Jira + // Cache miss: check if cache is cold and trigger background warming + // Note: Background cache warming removed - syncs must be triggered manually from GUI + // The isWarm() check is kept for status reporting, but no auto-warming + + // Fetch from Jira (don't wait for warming) return this.fetchAndCacheObject(typeName, id); } @@ -122,13 +126,48 @@ class CMDBService { ): Promise { try { const jiraObj = await jiraAssetsClient.getObject(id); - if (!jiraObj) return null; - - const parsed = jiraAssetsClient.parseObject(jiraObj); - if (parsed) { - await cacheStore.upsertObject(typeName, parsed); - await cacheStore.extractAndStoreRelations(typeName, parsed); + if (!jiraObj) { + logger.warn(`CMDBService: Jira API returned null for object ${typeName}/${id}`); + return null; } + + let parsed: T | null; + try { + parsed = await jiraAssetsClient.parseObject(jiraObj); + } catch (parseError) { + // parseObject throws errors for missing required fields - log and return null + logger.error(`CMDBService: Failed to parse object ${typeName}/${id} from Jira:`, parseError); + logger.debug(`CMDBService: Jira object that failed to parse:`, { + id: jiraObj.id, + objectKey: jiraObj.objectKey, + label: jiraObj.label, + objectType: jiraObj.objectType?.name, + attributesCount: jiraObj.attributes?.length || 0, + }); + return null; + } + + if (!parsed) { + logger.warn(`CMDBService: Failed to parse object ${typeName}/${id} from Jira (parseObject returned null)`); + return null; + } + + // Validate parsed object has required fields before caching + if (!parsed.id || !parsed.objectKey || !parsed.label) { + logger.error(`CMDBService: Parsed object ${typeName}/${id} is missing required fields. Parsed object: ${JSON.stringify({ + id: parsed.id, + objectKey: parsed.objectKey, + label: parsed.label, + hasId: 'id' in parsed, + hasObjectKey: 'objectKey' in parsed, + hasLabel: 'label' in parsed, + resultKeys: Object.keys(parsed), + })}`); + return null; // Return null instead of throwing to allow graceful degradation + } + + await cacheStore.upsertObject(typeName, parsed); + await cacheStore.extractAndStoreRelations(typeName, parsed); return parsed; } catch (error) { // If object was deleted from Jira, remove it from our cache @@ -139,11 +178,48 @@ class CMDBService { } return null; } - // Re-throw other errors - throw error; + // Log other errors but return null instead of throwing to prevent cascading failures + logger.error(`CMDBService: Unexpected error fetching object ${typeName}/${id}:`, error); + return null; } } + /** + * Batch fetch multiple objects from Jira and update cache + * Much more efficient than fetching objects one by one + */ + async batchFetchAndCacheObjects( + typeName: CMDBObjectTypeName, + ids: string[] + ): Promise { + if (ids.length === 0) return []; + + logger.debug(`CMDBService: Batch fetching ${ids.length} ${typeName} objects from Jira`); + + // Fetch all objects in parallel (but limit concurrency to avoid overwhelming Jira) + const BATCH_SIZE = 20; // Fetch 20 objects at a time + const results: T[] = []; + + for (let i = 0; i < ids.length; i += BATCH_SIZE) { + const batch = ids.slice(i, i + BATCH_SIZE); + const batchPromises = batch.map(async (id) => { + try { + return await this.fetchAndCacheObject(typeName, id); + } catch (error) { + logger.warn(`CMDBService: Failed to fetch ${typeName}/${id} in batch`, error); + return null; + } + }); + + const batchResults = await Promise.all(batchPromises); + const validResults = batchResults.filter((obj): obj is T => obj !== null); + results.push(...validResults); + } + + logger.debug(`CMDBService: Successfully batch fetched ${results.length}/${ids.length} ${typeName} objects`); + return results; + } + /** * Get all objects of a type from cache */ @@ -430,6 +506,20 @@ class CMDBService { return await cacheStore.isWarm(); } + /** + * Trigger background cache warming if cache is cold + * This is called on-demand when cache misses occur + */ + private async triggerBackgroundWarming(): Promise { + try { + const { jiraAssetsService } = await import('./jiraAssets.js'); + await jiraAssetsService.preWarmFullCache(); + } catch (error) { + // Silently fail - warming is optional + logger.debug('On-demand cache warming failed', error); + } + } + /** * Clear cache for a specific type */ diff --git a/backend/src/services/dataIntegrityService.ts b/backend/src/services/dataIntegrityService.ts new file mode 100644 index 0000000..284f828 --- /dev/null +++ b/backend/src/services/dataIntegrityService.ts @@ -0,0 +1,284 @@ +/** + * Data Integrity Service + * + * Handles validation and repair of broken references and other data integrity issues. + */ + +import { logger } from './logger.js'; +import { normalizedCacheStore as cacheStore } from './normalizedCacheStore.js'; +import { jiraAssetsClient, JiraObjectNotFoundError } from './jiraAssetsClient.js'; +import type { CMDBObject } from '../generated/jira-types.js'; + +export interface BrokenReference { + object_id: string; + attribute_id: number; + reference_object_id: string; + field_name: string; + object_type_name: string; + object_key: string; + label: string; +} + +export interface RepairResult { + total: number; + repaired: number; + deleted: number; + failed: number; + errors: Array<{ reference: BrokenReference; error: string }>; +} + +export interface ValidationResult { + brokenReferences: number; + objectsWithBrokenRefs: number; + lastValidated: string; +} + +class DataIntegrityService { + /** + * Validate all references in the cache + */ + async validateReferences(): Promise { + const brokenCount = await cacheStore.getBrokenReferencesCount(); + + // Count unique objects with broken references + const brokenRefs = await cacheStore.getBrokenReferences(10000, 0); + const uniqueObjectIds = new Set(brokenRefs.map(ref => ref.object_id)); + + return { + brokenReferences: brokenCount, + objectsWithBrokenRefs: uniqueObjectIds.size, + lastValidated: new Date().toISOString(), + }; + } + + /** + * Repair broken references + * + * @param mode - 'delete': Remove broken references, 'fetch': Try to fetch missing objects from Jira, 'dry-run': Just report + * @param batchSize - Number of references to process at a time + * @param maxRepairs - Maximum number of repairs to attempt (0 = unlimited) + */ + async repairBrokenReferences( + mode: 'delete' | 'fetch' | 'dry-run' = 'fetch', + batchSize: number = 100, + maxRepairs: number = 0 + ): Promise { + const result: RepairResult = { + total: 0, + repaired: 0, + deleted: 0, + failed: 0, + errors: [], + }; + + let offset = 0; + let processed = 0; + + while (true) { + // Fetch batch of broken references + const brokenRefs = await cacheStore.getBrokenReferences(batchSize, offset); + + if (brokenRefs.length === 0) break; + + result.total += brokenRefs.length; + + for (const ref of brokenRefs) { + // Check max repairs limit + if (maxRepairs > 0 && processed >= maxRepairs) { + logger.info(`DataIntegrityService: Reached max repairs limit (${maxRepairs})`); + break; + } + + try { + if (mode === 'dry-run') { + // Just count, don't repair + processed++; + continue; + } + + if (mode === 'fetch') { + // Try to fetch the referenced object from Jira + const fetchResult = await this.validateAndFetchReference(ref.reference_object_id); + + if (fetchResult.exists && fetchResult.object) { + // Object was successfully fetched and cached + logger.debug(`DataIntegrityService: Repaired reference from ${ref.object_key}.${ref.field_name} to ${ref.reference_object_id}`); + result.repaired++; + } else { + // Object doesn't exist in Jira, delete the reference + await this.deleteBrokenReference(ref); + logger.debug(`DataIntegrityService: Deleted broken reference from ${ref.object_key}.${ref.field_name} to ${ref.reference_object_id} (object not found in Jira)`); + result.deleted++; + } + } else if (mode === 'delete') { + // Directly delete the broken reference + await this.deleteBrokenReference(ref); + result.deleted++; + } + + processed++; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`DataIntegrityService: Failed to repair reference from ${ref.object_key}.${ref.field_name} to ${ref.reference_object_id}`, error); + result.failed++; + result.errors.push({ + reference: ref, + error: errorMessage, + }); + } + } + + // Check if we should continue + if (brokenRefs.length < batchSize || (maxRepairs > 0 && processed >= maxRepairs)) { + break; + } + + offset += batchSize; + } + + logger.info(`DataIntegrityService: Repair completed - Total: ${result.total}, Repaired: ${result.repaired}, Deleted: ${result.deleted}, Failed: ${result.failed}`); + + return result; + } + + /** + * Validate and fetch a referenced object + */ + private async validateAndFetchReference( + referenceObjectId: string + ): Promise<{ exists: boolean; object?: CMDBObject }> { + // 1. Check cache first + const db = (cacheStore as any).db; + if (db) { + const objRow = await db.queryOne<{ + id: string; + object_type_name: string; + }>(` + SELECT id, object_type_name + FROM objects + WHERE id = ? + `, [referenceObjectId]); + + if (objRow) { + const cached = await cacheStore.getObject(objRow.object_type_name as any, referenceObjectId); + if (cached) { + return { exists: true, object: cached }; + } + } + } + + // 2. Try to fetch from Jira + try { + const jiraObj = await jiraAssetsClient.getObject(referenceObjectId); + if (jiraObj) { + // Parse and cache + const parsed = await jiraAssetsClient.parseObject(jiraObj); + if (parsed) { + await cacheStore.upsertObject(parsed._objectType, parsed); + await cacheStore.extractAndStoreRelations(parsed._objectType, parsed); + return { exists: true, object: parsed }; + } + } + } catch (error) { + if (error instanceof JiraObjectNotFoundError) { + return { exists: false }; + } + // Re-throw other errors + throw error; + } + + return { exists: false }; + } + + /** + * Delete a broken reference + */ + private async deleteBrokenReference(ref: BrokenReference): Promise { + const db = (cacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + await db.execute(` + DELETE FROM attribute_values + WHERE object_id = ? + AND attribute_id = ? + AND reference_object_id = ? + `, [ref.object_id, ref.attribute_id, ref.reference_object_id]); + } + + /** + * Cleanup orphaned attribute values (values without parent object) + */ + async cleanupOrphanedAttributeValues(): Promise { + const db = (cacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + const result = await db.execute(` + DELETE FROM attribute_values + WHERE object_id NOT IN (SELECT id FROM objects) + `); + + logger.info(`DataIntegrityService: Cleaned up ${result} orphaned attribute values`); + return result; + } + + /** + * Cleanup orphaned relations (relations where source or target doesn't exist) + */ + async cleanupOrphanedRelations(): Promise { + const db = (cacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + const result = await db.execute(` + DELETE FROM object_relations + WHERE source_id NOT IN (SELECT id FROM objects) + OR target_id NOT IN (SELECT id FROM objects) + `); + + logger.info(`DataIntegrityService: Cleaned up ${result} orphaned relations`); + return result; + } + + /** + * Full integrity check and repair + */ + async fullIntegrityCheck(repair: boolean = false): Promise<{ + validation: ValidationResult; + repair?: RepairResult; + orphanedValues: number; + orphanedRelations: number; + }> { + logger.info('DataIntegrityService: Starting full integrity check...'); + + const validation = await this.validateReferences(); + const orphanedValues = await this.cleanupOrphanedAttributeValues(); + const orphanedRelations = await this.cleanupOrphanedRelations(); + + let repairResult: RepairResult | undefined; + if (repair) { + repairResult = await this.repairBrokenReferences('fetch', 100, 0); + } + + logger.info('DataIntegrityService: Integrity check completed', { + brokenReferences: validation.brokenReferences, + orphanedValues, + orphanedRelations, + repaired: repairResult?.repaired || 0, + deleted: repairResult?.deleted || 0, + }); + + return { + validation, + repair: repairResult, + orphanedValues, + orphanedRelations, + }; + } +} + +export const dataIntegrityService = new DataIntegrityService(); diff --git a/backend/src/services/dataService.ts b/backend/src/services/dataService.ts index 6bb8855..06dab4d 100644 --- a/backend/src/services/dataService.ts +++ b/backend/src/services/dataService.ts @@ -1,17 +1,16 @@ /** * 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 + * ALWAYS uses Jira Assets API via CMDBService (local cache layer). + * Mock data has been removed - all data must come from Jira Assets. */ import { config } from '../config/env.js'; import { cmdbService, type UpdateResult } from './cmdbService.js'; -import { cacheStore, type CacheStats } from './cacheStore.js'; +import { normalizedCacheStore as cacheStore, type CacheStats } from './normalizedCacheStore.js'; +import { normalizedCacheStore } from './normalizedCacheStore.js'; import { jiraAssetsClient } from './jiraAssetsClient.js'; import { jiraAssetsService } from './jiraAssets.js'; -import { mockDataService } from './mockData.js'; import { logger } from './logger.js'; import type { ApplicationComponent, @@ -47,16 +46,8 @@ import type { import { calculateRequiredEffortWithMinMax } from './effortCalculation.js'; import { calculateApplicationCompleteness } from './dataCompletenessConfig.js'; -// Determine if we should use real Jira Assets or mock data -// Jira PAT is now configured per-user, so we check if schema is configured -// The actual PAT is provided per-request via middleware -const useJiraAssets = !!config.jiraSchemaId; - -if (useJiraAssets) { - logger.info('DataService: Using CMDB cache layer with Jira Assets API'); -} else { - logger.info('DataService: Using mock data (Jira credentials not configured)'); -} +// NOTE: All data comes from Jira Assets API - no mock data fallback +// If schemas aren't configured yet, operations will fail gracefully with appropriate errors // ============================================================================= // Reference Cache (for enriching IDs to ObjectReferences) @@ -121,42 +112,111 @@ async function lookupReferences( // Helper Functions // ============================================================================= +/** + * Load description for an object from database + * Looks for a description attribute (field_name like 'description' or attr_name like 'Description') + */ +async function getDescriptionFromDatabase(objectId: string): Promise { + try { + const { normalizedCacheStore } = await import('./normalizedCacheStore.js'); + const db = (normalizedCacheStore as any).db; + if (!db) return null; + + // Try to find description attribute by common field names + const descriptionFieldNames = ['description', 'Description', 'DESCRIPTION']; + + // First, get the object to find its type + const objRow = await db.queryOne<{ object_type_name: string }>(` + SELECT object_type_name FROM objects WHERE id = ? + `, [objectId]); + + if (!objRow) return null; + + // Try each possible description field name + for (const fieldName of descriptionFieldNames) { + const descRow = await db.queryOne<{ text_value: string }>(` + SELECT av.text_value + FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = ? + AND (a.field_name = ? OR a.attr_name = ?) + AND av.text_value IS NOT NULL + AND av.text_value != '' + LIMIT 1 + `, [objectId, fieldName, fieldName]); + + if (descRow?.text_value) { + return descRow.text_value; + } + } + + return null; + } catch (error) { + logger.debug(`Failed to get description from database for object ${objectId}`, error); + return null; + } +} + /** * Convert ObjectReference to ReferenceValue format used by frontend - * Try to enrich with description from jiraAssetsService cache if available - * If not in cache or cache entry has no description, fetch it async + * PRIMARY: Load from database cache (no API calls) + * FALLBACK: Only use API if object not in database */ async function toReferenceValue(ref: ObjectReference | null | undefined): Promise { if (!ref) return null; - // Try to get enriched ReferenceValue from jiraAssetsService cache (includes description if available) - const enriched = useJiraAssets ? jiraAssetsService.getEnrichedReferenceValue(ref.objectKey, ref.objectId) : null; + // PRIMARY SOURCE: Try to load from database first (no API calls) + try { + const { normalizedCacheStore } = await import('./normalizedCacheStore.js'); + const db = (normalizedCacheStore as any).db; + if (db) { + await db.ensureInitialized?.(); + + // Get basic object info from database + const objRow = await db.queryOne<{ + id: string; + object_key: string; + label: string; + }>(` + SELECT id, object_key, label + FROM objects + WHERE id = ? OR object_key = ? + LIMIT 1 + `, [ref.objectId, ref.objectKey]); + + if (objRow) { + // Object exists in database - extract description if available + const description = await getDescriptionFromDatabase(objRow.id); + + return { + objectId: objRow.id, + key: objRow.object_key || ref.objectKey, + name: objRow.label || ref.label, + ...(description && { description }), + }; + } + } + } catch (error) { + logger.debug(`Failed to load reference object ${ref.objectId} from database`, error); + } + + // FALLBACK: Object not in database - check Jira Assets service cache + // Only fetch from API if really needed (object missing from database) + const enriched = jiraAssetsService.getEnrichedReferenceValue(ref.objectKey, ref.objectId); if (enriched && enriched.description) { - // Use enriched value with description + // Use enriched value with description from service cache return enriched; } - // Cache miss or no description - fetch it async if using Jira Assets - if (useJiraAssets && enriched && !enriched.description) { - // We have a cached value but it lacks description - fetch it - const fetched = await jiraAssetsService.fetchEnrichedReferenceValue(ref.objectKey, ref.objectId); - if (fetched) { - return fetched; - } - // If fetch failed, return the cached value anyway + // Last resort: Object not in database and not in service cache + // Only return basic info - don't fetch from API here + // API fetching should only happen during sync operations + if (enriched) { return enriched; } - if (useJiraAssets) { - // Cache miss - fetch it - const fetched = await jiraAssetsService.fetchEnrichedReferenceValue(ref.objectKey, ref.objectId); - if (fetched) { - return fetched; - } - } - - // Fallback to basic conversion without description (if fetch failed or not using Jira Assets) + // Basic fallback - return what we have from the ObjectReference return { objectId: ref.objectId, key: ref.objectKey, @@ -172,7 +232,8 @@ function toReferenceValues(refs: ObjectReference[] | null | undefined): Referenc return refs.map(ref => ({ objectId: ref.objectId, key: ref.objectKey, - name: ref.label, + // Use label if available, otherwise fall back to objectKey, then objectId + name: ref.label || ref.objectKey || ref.objectId || 'Unknown', })); } @@ -225,6 +286,18 @@ async function toApplicationDetails(app: ApplicationComponent): Promise { - if (!useJiraAssets) { - return mockDataService.searchApplications(filters, page, pageSize); - } - - // Get all applications from cache + // Get all applications from cache (always from Jira Assets) let apps = await cmdbService.getObjects('ApplicationComponent'); + logger.debug(`DataService: Found ${apps.length} applications in cache for search`); + + // If cache is empty, log a warning + if (apps.length === 0) { + logger.warn('DataService: Cache is empty - no applications found. A full sync may be needed.'); + } + // Apply filters locally - if (filters.searchText) { - 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.searchText && filters.searchText.trim()) { + const search = filters.searchText.toLowerCase().trim(); + const beforeFilter = apps.length; + apps = apps.filter(app => { + const label = app.label?.toLowerCase() || ''; + const objectKey = app.objectKey?.toLowerCase() || ''; + const searchRef = app.searchReference?.toLowerCase() || ''; + const description = app.description?.toLowerCase() || ''; + return label.includes(search) || + objectKey.includes(search) || + searchRef.includes(search) || + description.includes(search); + }); + logger.debug(`DataService: Search filter "${filters.searchText}" reduced results from ${beforeFilter} to ${apps.length}`); } if (filters.statuses && filters.statuses.length > 0) { @@ -834,11 +879,14 @@ export const dataService = { * Get application by ID (from cache) */ async getApplicationById(id: string): Promise { - if (!useJiraAssets) { - return mockDataService.getApplicationById(id); + // Try to get by ID first (handles both Jira object IDs and object keys) + let app = await cmdbService.getObject('ApplicationComponent', id); + + // If not found by ID, try by object key (e.g., "ICMT-123" or numeric IDs that might be keys) + if (!app) { + app = await cmdbService.getObjectByKey('ApplicationComponent', id); } - - const app = await cmdbService.getObject('ApplicationComponent', id); + if (!app) return null; return toApplicationDetails(app); @@ -848,13 +896,18 @@ export const dataService = { * 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, { + // Try to get by ID first (handles both Jira object IDs and object keys) + let app = await cmdbService.getObject('ApplicationComponent', id, { forceRefresh: true, }); + + // If not found by ID, try by object key (e.g., "ICMT-123" or numeric IDs that might be keys) + if (!app) { + app = await cmdbService.getObjectByKey('ApplicationComponent', id, { + forceRefresh: true, + }); + } + if (!app) return null; return toApplicationDetails(app); @@ -884,11 +937,7 @@ export const dataService = { ): Promise { logger.info(`dataService.updateApplication called for ${id}`); - if (!useJiraAssets) { - const success = await mockDataService.updateApplication(id, updates); - return { success }; - } - + // Always update via Jira Assets API // Convert to CMDBService format // IMPORTANT: For reference fields, we pass ObjectReference objects (with objectKey) // because buildAttributeValues in cmdbService expects to extract objectKey for Jira API @@ -978,7 +1027,7 @@ export const dataService = { // =========================================================================== async getDynamicsFactors(): Promise { - if (!useJiraAssets) return mockDataService.getDynamicsFactors(); + // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationManagementDynamicsFactor'); return items.map(item => ({ objectId: item.id, @@ -991,7 +1040,7 @@ export const dataService = { }, async getComplexityFactors(): Promise { - if (!useJiraAssets) return mockDataService.getComplexityFactors(); + // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationManagementComplexityFactor'); return items.map(item => ({ objectId: item.id, @@ -1004,7 +1053,7 @@ export const dataService = { }, async getNumberOfUsers(): Promise { - if (!useJiraAssets) return mockDataService.getNumberOfUsers(); + // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationManagementNumberOfUsers'); return items.map(item => ({ objectId: item.id, @@ -1017,7 +1066,7 @@ export const dataService = { }, async getGovernanceModels(): Promise { - if (!useJiraAssets) return mockDataService.getGovernanceModels(); + // Always get from Jira Assets cache const items = await cmdbService.getObjects('IctGovernanceModel'); return items.map(item => ({ objectId: item.id, @@ -1030,24 +1079,26 @@ export const dataService = { }, async getOrganisations(): Promise { - if (!useJiraAssets) return mockDataService.getOrganisations(); + // Always get from Jira Assets cache const items = await cmdbService.getObjects('Organisation'); + logger.debug(`DataService: Found ${items.length} organisations in cache`); return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label })); }, async getHostingTypes(): Promise { - if (!useJiraAssets) return mockDataService.getHostingTypes(); + // Always get from Jira Assets cache const items = await cmdbService.getObjects('HostingType'); - return items.map(item => ({ - objectId: item.id, - key: item.objectKey, + logger.debug(`DataService: Found ${items.length} hosting types in cache`); + return items.map(item => ({ + objectId: item.id, + key: item.objectKey, name: item.label, summary: item.description || undefined, // Use description as summary for display })); }, async getBusinessImpactAnalyses(): Promise { - if (!useJiraAssets) return mockDataService.getBusinessImpactAnalyses(); + // Always get from Jira Assets cache const items = await cmdbService.getObjects('BusinessImpactAnalyse'); return items.map(item => ({ objectId: item.id, @@ -1059,7 +1110,7 @@ export const dataService = { }, async getApplicationManagementHosting(): Promise { - if (!useJiraAssets) return mockDataService.getApplicationManagementHosting(); + // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationManagementHosting'); return items.map(item => ({ objectId: item.id, @@ -1070,7 +1121,7 @@ export const dataService = { }, async getApplicationManagementTAM(): Promise { - if (!useJiraAssets) return mockDataService.getApplicationManagementTAM(); + // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationManagementTam'); return items.map(item => ({ objectId: item.id, @@ -1081,7 +1132,7 @@ export const dataService = { }, async getApplicationFunctions(): Promise { - if (!useJiraAssets) return mockDataService.getApplicationFunctions(); + // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationFunction'); return items.map(item => ({ objectId: item.id, @@ -1098,7 +1149,7 @@ export const dataService = { }, async getApplicationFunctionCategories(): Promise { - if (!useJiraAssets) return mockDataService.getApplicationFunctionCategories(); + // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationFunctionCategory'); return items.map(item => ({ objectId: item.id, @@ -1109,19 +1160,17 @@ export const dataService = { }, async getApplicationSubteams(): Promise { - if (!useJiraAssets) return []; // Mock mode: no subteams - // Use jiraAssetsService directly as schema doesn't include this object type + // Always get from Jira Assets API (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 + // Always get from Jira Assets API (schema doesn't include this object type) return jiraAssetsService.getApplicationTeams(); }, async getSubteamToTeamMapping(): Promise> { - if (!useJiraAssets) return {}; // Mock mode: no mapping + // Always get from Jira Assets API // Convert Map to plain object for JSON serialization const mapping = await jiraAssetsService.getSubteamToTeamMapping(); const result: Record = {}; @@ -1132,7 +1181,7 @@ export const dataService = { }, async getApplicationTypes(): Promise { - if (!useJiraAssets) return mockDataService.getApplicationTypes(); + // Always get from Jira Assets cache const items = await cmdbService.getObjects('ApplicationManagementApplicationType'); return items.map(item => ({ objectId: item.id, @@ -1143,8 +1192,9 @@ export const dataService = { }, async getBusinessImportance(): Promise { - if (!useJiraAssets) return mockDataService.getBusinessImportance(); + // Always get from Jira Assets cache const items = await cmdbService.getObjects('BusinessImportance'); + logger.debug(`DataService: Found ${items.length} business importance values in cache`); return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label })); }, @@ -1153,8 +1203,7 @@ export const dataService = { // =========================================================================== async getStats(includeDistributions: boolean = true) { - if (!useJiraAssets) return mockDataService.getStats(); - + // Always get from Jira Assets cache const allApps = await cmdbService.getObjects('ApplicationComponent'); // Statuses to exclude for most metrics @@ -1231,9 +1280,7 @@ export const dataService = { }, async getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise { - if (!useJiraAssets) return mockDataService.getTeamDashboardData(excludedStatuses); - - // Use jiraAssetsService directly as it has proper Team/Subteam field parsing + // Always get from Jira Assets API (has proper Team/Subteam field parsing) return jiraAssetsService.getTeamDashboardData(excludedStatuses); }, @@ -1253,7 +1300,7 @@ export const dataService = { applicationCount: number; }>; }> { - // For mock data, use the same implementation (cmdbService routes to mock data when useJiraAssets is false) + // Always get from Jira Assets cache // Get all applications from cache to access all fields including BIA let apps = await cmdbService.getObjects('ApplicationComponent'); @@ -1421,13 +1468,13 @@ export const dataService = { // Utility // =========================================================================== - isUsingJiraAssets(): boolean { - return useJiraAssets; + async isUsingJiraAssets(): Promise { + // Always returns true - mock data removed, only Jira Assets is used + return true; }, async testConnection(): Promise { - if (!useJiraAssets) return true; - // Only test connection if token is configured + // Always test Jira Assets connection (requires token) if (!jiraAssetsClient.hasToken()) { return false; } diff --git a/backend/src/services/database/fix-object-types-constraints.ts b/backend/src/services/database/fix-object-types-constraints.ts new file mode 100644 index 0000000..49b011a --- /dev/null +++ b/backend/src/services/database/fix-object-types-constraints.ts @@ -0,0 +1,123 @@ +/** + * Fix UNIQUE constraints on object_types table + * + * Removes old UNIQUE constraint on type_name and adds new UNIQUE(schema_id, type_name) + * This allows the same type_name to exist in different schemas + */ + +import { logger } from '../logger.js'; +import { normalizedCacheStore } from '../normalizedCacheStore.js'; + +export async function fixObjectTypesConstraints(): Promise { + const db = (normalizedCacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + await db.ensureInitialized?.(); + + logger.info('Migration: Fixing UNIQUE constraints on object_types table...'); + + try { + if (db.isPostgres) { + // Check if old constraint exists + const oldConstraintExists = await db.queryOne<{ count: number }>(` + SELECT COUNT(*) as count + FROM pg_constraint + WHERE conname = 'object_types_type_name_key' + `); + + if (oldConstraintExists && oldConstraintExists.count > 0) { + logger.info('Migration: Dropping old UNIQUE constraint on type_name...'); + await db.execute(`ALTER TABLE object_types DROP CONSTRAINT IF EXISTS object_types_type_name_key`); + } + + // Check if new constraint exists + const newConstraintExists = await db.queryOne<{ count: number }>(` + SELECT COUNT(*) as count + FROM pg_constraint + WHERE conname = 'object_types_schema_id_type_name_key' + `); + + if (!newConstraintExists || newConstraintExists.count === 0) { + logger.info('Migration: Adding UNIQUE constraint on (schema_id, type_name)...'); + try { + await db.execute(` + ALTER TABLE object_types + ADD CONSTRAINT object_types_schema_id_type_name_key UNIQUE (schema_id, type_name) + `); + } catch (error: any) { + // If constraint already exists or there are duplicates, log and continue + if (error.message && error.message.includes('already exists')) { + logger.debug('Migration: Constraint already exists, skipping'); + } else if (error.message && error.message.includes('duplicate key')) { + logger.warn('Migration: Duplicate (schema_id, type_name) found - this may need manual cleanup'); + // Don't throw - allow the application to continue + } else { + throw error; + } + } + } else { + logger.debug('Migration: New UNIQUE constraint already exists'); + } + } else { + // SQLite: UNIQUE constraints are part of table definition + // We can't easily modify them, but the schema definition should handle it + logger.debug('Migration: SQLite UNIQUE constraints are handled in table definition'); + } + + // Step 2: Remove foreign key constraints that reference object_types(type_name) + logger.info('Migration: Removing foreign key constraints on object_types(type_name)...'); + try { + if (db.isPostgres) { + // Check and drop foreign keys from attributes table + const attrFkExists = await db.queryOne<{ count: number }>(` + SELECT COUNT(*) as count + FROM pg_constraint + WHERE conname LIKE 'attributes_object_type_name_fkey%' + `); + + if (attrFkExists && attrFkExists.count > 0) { + logger.info('Migration: Dropping foreign key from attributes table...'); + await db.execute(`ALTER TABLE attributes DROP CONSTRAINT IF EXISTS attributes_object_type_name_fkey`); + } + + // Check and drop foreign keys from objects table + const objFkExists = await db.queryOne<{ count: number }>(` + SELECT COUNT(*) as count + FROM pg_constraint + WHERE conname LIKE 'objects_object_type_name_fkey%' + `); + + if (objFkExists && objFkExists.count > 0) { + logger.info('Migration: Dropping foreign key from objects table...'); + await db.execute(`ALTER TABLE objects DROP CONSTRAINT IF EXISTS objects_object_type_name_fkey`); + } + + // Check and drop foreign keys from schema_mappings table + const mappingFkExists = await db.queryOne<{ count: number }>(` + SELECT COUNT(*) as count + FROM pg_constraint + WHERE conname LIKE 'schema_mappings_object_type_name_fkey%' + `); + + if (mappingFkExists && mappingFkExists.count > 0) { + logger.info('Migration: Dropping foreign key from schema_mappings table...'); + await db.execute(`ALTER TABLE schema_mappings DROP CONSTRAINT IF EXISTS schema_mappings_object_type_name_fkey`); + } + } else { + // SQLite: Foreign keys are part of table definition + // We can't easily drop them, but the new schema definition should handle it + logger.debug('Migration: SQLite foreign keys are handled in table definition'); + } + } catch (error) { + logger.warn('Migration: Could not remove foreign key constraints (may not exist)', error); + // Don't throw - allow the application to continue + } + + logger.info('Migration: UNIQUE constraints and foreign keys fix completed'); + } catch (error) { + logger.warn('Migration: Could not fix constraints (may already be correct)', error); + // Don't throw - allow the application to continue + } +} diff --git a/backend/src/services/database/interface.ts b/backend/src/services/database/interface.ts index 41022c6..81cbc22 100644 --- a/backend/src/services/database/interface.ts +++ b/backend/src/services/database/interface.ts @@ -40,4 +40,9 @@ export interface DatabaseAdapter { * Get database size in bytes (if applicable) */ getSizeBytes?(): Promise; + + /** + * Indicates if this is a PostgreSQL adapter + */ + isPostgres?: boolean; } diff --git a/backend/src/services/database/migrate-to-normalized-schema.ts b/backend/src/services/database/migrate-to-normalized-schema.ts new file mode 100644 index 0000000..35421f4 --- /dev/null +++ b/backend/src/services/database/migrate-to-normalized-schema.ts @@ -0,0 +1,417 @@ +/** + * Migration script to migrate from configured_object_types to normalized schema structure + * + * This script: + * 1. Creates schemas table if it doesn't exist + * 2. Migrates unique schemas from configured_object_types to schemas + * 3. Adds schema_id and enabled columns to object_types if they don't exist + * 4. Migrates object types from configured_object_types to object_types with schema_id FK + * 5. Drops configured_object_types table after successful migration + */ + +import { logger } from '../logger.js'; +import { normalizedCacheStore } from '../normalizedCacheStore.js'; + +export async function migrateToNormalizedSchema(): Promise { + const db = (normalizedCacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + await db.ensureInitialized?.(); + + logger.info('Migration: Starting migration to normalized schema structure...'); + + try { + await db.transaction(async (txDb) => { + // Step 1: Check if configured_object_types table exists + let configuredTableExists = false; + try { + if (txDb.isPostgres) { + const result = await txDb.queryOne<{ count: number }>(` + SELECT COUNT(*) as count + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'configured_object_types' + `); + configuredTableExists = (result?.count || 0) > 0; + } else { + const result = await txDb.queryOne<{ count: number }>(` + SELECT COUNT(*) as count + FROM sqlite_master + WHERE type='table' AND name='configured_object_types' + `); + configuredTableExists = (result?.count || 0) > 0; + } + } catch (error) { + logger.debug('Migration: configured_object_types table check failed (may not exist)', error); + } + + if (!configuredTableExists) { + logger.info('Migration: configured_object_types table does not exist, skipping migration'); + return; + } + + // Step 2: Check if schemas table exists, create if not + let schemasTableExists = false; + try { + if (txDb.isPostgres) { + const result = await txDb.queryOne<{ count: number }>(` + SELECT COUNT(*) as count + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'schemas' + `); + schemasTableExists = (result?.count || 0) > 0; + } else { + const result = await txDb.queryOne<{ count: number }>(` + SELECT COUNT(*) as count + FROM sqlite_master + WHERE type='table' AND name='schemas' + `); + schemasTableExists = (result?.count || 0) > 0; + } + } catch (error) { + logger.debug('Migration: schemas table check failed', error); + } + + if (!schemasTableExists) { + logger.info('Migration: Creating schemas table...'); + if (txDb.isPostgres) { + await txDb.execute(` + CREATE TABLE IF NOT EXISTS schemas ( + id SERIAL PRIMARY KEY, + jira_schema_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + discovered_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + `); + await txDb.execute(` + CREATE INDEX IF NOT EXISTS idx_schemas_jira_schema_id ON schemas(jira_schema_id) + `); + await txDb.execute(` + CREATE INDEX IF NOT EXISTS idx_schemas_name ON schemas(name) + `); + } else { + await txDb.execute(` + CREATE TABLE IF NOT EXISTS schemas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + jira_schema_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + discovered_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + await txDb.execute(` + CREATE INDEX IF NOT EXISTS idx_schemas_jira_schema_id ON schemas(jira_schema_id) + `); + await txDb.execute(` + CREATE INDEX IF NOT EXISTS idx_schemas_name ON schemas(name) + `); + } + } + + // Step 3: Migrate unique schemas from configured_object_types to schemas + logger.info('Migration: Migrating schemas from configured_object_types...'); + const schemaRows = await txDb.query<{ + schema_id: string; + schema_name: string; + min_discovered_at: string; + max_updated_at: string; + }>(` + SELECT + schema_id, + schema_name, + MIN(discovered_at) as min_discovered_at, + MAX(updated_at) as max_updated_at + FROM configured_object_types + GROUP BY schema_id, schema_name + `); + + for (const schemaRow of schemaRows) { + if (txDb.isPostgres) { + await txDb.execute(` + INSERT INTO schemas (jira_schema_id, name, description, discovered_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(jira_schema_id) DO UPDATE SET + name = excluded.name, + updated_at = excluded.updated_at + `, [ + schemaRow.schema_id, + schemaRow.schema_name, + null, + schemaRow.min_discovered_at, + schemaRow.max_updated_at, + ]); + } else { + await txDb.execute(` + INSERT INTO schemas (jira_schema_id, name, description, discovered_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(jira_schema_id) DO UPDATE SET + name = excluded.name, + updated_at = excluded.updated_at + `, [ + schemaRow.schema_id, + schemaRow.schema_name, + null, + schemaRow.min_discovered_at, + schemaRow.max_updated_at, + ]); + } + } + logger.info(`Migration: Migrated ${schemaRows.length} schemas`); + + // Step 4: Check if object_types has schema_id and enabled columns + let hasSchemaId = false; + let hasEnabled = false; + try { + if (txDb.isPostgres) { + const columns = await txDb.query<{ column_name: string }>(` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'object_types' + `); + hasSchemaId = columns.some(c => c.column_name === 'schema_id'); + hasEnabled = columns.some(c => c.column_name === 'enabled'); + } else { + const tableInfo = await txDb.query<{ name: string }>(` + PRAGMA table_info(object_types) + `); + hasSchemaId = tableInfo.some(c => c.name === 'schema_id'); + hasEnabled = tableInfo.some(c => c.name === 'enabled'); + } + } catch (error) { + logger.warn('Migration: Could not check object_types columns', error); + } + + // Step 5: Add schema_id and enabled columns if they don't exist + if (!hasSchemaId) { + logger.info('Migration: Adding schema_id column to object_types...'); + if (txDb.isPostgres) { + await txDb.execute(` + ALTER TABLE object_types + ADD COLUMN schema_id INTEGER REFERENCES schemas(id) ON DELETE CASCADE + `); + } else { + // SQLite doesn't support ALTER TABLE ADD COLUMN with FK, so we'll handle it differently + // For now, just add the column without FK constraint + await txDb.execute(` + ALTER TABLE object_types + ADD COLUMN schema_id INTEGER + `); + } + } + + if (!hasEnabled) { + logger.info('Migration: Adding enabled column to object_types...'); + if (txDb.isPostgres) { + await txDb.execute(` + ALTER TABLE object_types + ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT FALSE + `); + } else { + await txDb.execute(` + ALTER TABLE object_types + ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0 + `); + } + } + + // Step 6: Migrate object types from configured_object_types to object_types + logger.info('Migration: Migrating object types from configured_object_types...'); + const configuredTypes = await txDb.query<{ + schema_id: string; + object_type_id: number; + object_type_name: string; + display_name: string; + description: string | null; + object_count: number; + enabled: boolean | number; + discovered_at: string; + updated_at: string; + }>(` + SELECT + schema_id, + object_type_id, + object_type_name, + display_name, + description, + object_count, + enabled, + discovered_at, + updated_at + FROM configured_object_types + `); + + let migratedCount = 0; + for (const configuredType of configuredTypes) { + // Get schema_id (FK) from schemas table + const schemaRow = await txDb.queryOne<{ id: number }>( + `SELECT id FROM schemas WHERE jira_schema_id = ?`, + [configuredType.schema_id] + ); + + if (!schemaRow) { + logger.warn(`Migration: Schema ${configuredType.schema_id} not found, skipping object type ${configuredType.object_type_name}`); + continue; + } + + // Check if object type already exists in object_types + const existingType = await txDb.queryOne<{ jira_type_id: number }>( + `SELECT jira_type_id FROM object_types WHERE jira_type_id = ?`, + [configuredType.object_type_id] + ); + + if (existingType) { + // Update existing object type with schema_id and enabled + if (txDb.isPostgres) { + await txDb.execute(` + UPDATE object_types + SET + schema_id = ?, + enabled = ?, + display_name = COALESCE(display_name, ?), + description = COALESCE(description, ?), + object_count = COALESCE(object_count, ?), + updated_at = ? + WHERE jira_type_id = ? + `, [ + schemaRow.id, + typeof configuredType.enabled === 'boolean' ? configuredType.enabled : configuredType.enabled === 1, + configuredType.display_name, + configuredType.description, + configuredType.object_count, + configuredType.updated_at, + configuredType.object_type_id, + ]); + } else { + await txDb.execute(` + UPDATE object_types + SET + schema_id = ?, + enabled = ?, + display_name = COALESCE(display_name, ?), + description = COALESCE(description, ?), + object_count = COALESCE(object_count, ?), + updated_at = ? + WHERE jira_type_id = ? + `, [ + schemaRow.id, + typeof configuredType.enabled === 'boolean' ? (configuredType.enabled ? 1 : 0) : configuredType.enabled, + configuredType.display_name, + configuredType.description, + configuredType.object_count, + configuredType.updated_at, + configuredType.object_type_id, + ]); + } + } else { + // Insert new object type + // Note: We need sync_priority - use default 0 + if (txDb.isPostgres) { + await txDb.execute(` + INSERT INTO object_types ( + schema_id, jira_type_id, type_name, display_name, description, + sync_priority, object_count, enabled, discovered_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + schemaRow.id, + configuredType.object_type_id, + configuredType.object_type_name, + configuredType.display_name, + configuredType.description, + 0, // sync_priority + configuredType.object_count, + typeof configuredType.enabled === 'boolean' ? configuredType.enabled : configuredType.enabled === 1, + configuredType.discovered_at, + configuredType.updated_at, + ]); + } else { + await txDb.execute(` + INSERT INTO object_types ( + schema_id, jira_type_id, type_name, display_name, description, + sync_priority, object_count, enabled, discovered_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + schemaRow.id, + configuredType.object_type_id, + configuredType.object_type_name, + configuredType.display_name, + configuredType.description, + 0, // sync_priority + configuredType.object_count, + typeof configuredType.enabled === 'boolean' ? (configuredType.enabled ? 1 : 0) : configuredType.enabled, + configuredType.discovered_at, + configuredType.updated_at, + ]); + } + } + migratedCount++; + } + logger.info(`Migration: Migrated ${migratedCount} object types`); + + // Step 7: Fix UNIQUE constraints on object_types + logger.info('Migration: Fixing UNIQUE constraints on object_types...'); + try { + // Remove old UNIQUE constraint on type_name if it exists + if (txDb.isPostgres) { + // Check if constraint exists + const constraintExists = await txDb.queryOne<{ count: number }>(` + SELECT COUNT(*) as count + FROM pg_constraint + WHERE conname = 'object_types_type_name_key' + `); + + if (constraintExists && constraintExists.count > 0) { + logger.info('Migration: Dropping old UNIQUE constraint on type_name...'); + await txDb.execute(`ALTER TABLE object_types DROP CONSTRAINT IF EXISTS object_types_type_name_key`); + } + + // Add new UNIQUE constraint on (schema_id, type_name) + const newConstraintExists = await txDb.queryOne<{ count: number }>(` + SELECT COUNT(*) as count + FROM pg_constraint + WHERE conname = 'object_types_schema_id_type_name_key' + `); + + if (!newConstraintExists || newConstraintExists.count === 0) { + logger.info('Migration: Adding UNIQUE constraint on (schema_id, type_name)...'); + await txDb.execute(` + ALTER TABLE object_types + ADD CONSTRAINT object_types_schema_id_type_name_key UNIQUE (schema_id, type_name) + `); + } + } else { + // SQLite: UNIQUE constraints are part of table definition, so we need to recreate + // For now, just log a warning - SQLite doesn't support DROP CONSTRAINT easily + logger.info('Migration: SQLite UNIQUE constraints are handled in table definition'); + } + } catch (error) { + logger.warn('Migration: Could not fix UNIQUE constraints (may already be correct)', error); + } + + // Step 8: Add indexes if they don't exist + logger.info('Migration: Adding indexes...'); + try { + await txDb.execute(`CREATE INDEX IF NOT EXISTS idx_object_types_schema_id ON object_types(schema_id)`); + await txDb.execute(`CREATE INDEX IF NOT EXISTS idx_object_types_enabled ON object_types(enabled)`); + await txDb.execute(`CREATE INDEX IF NOT EXISTS idx_object_types_schema_enabled ON object_types(schema_id, enabled)`); + } catch (error) { + logger.warn('Migration: Some indexes may already exist', error); + } + + // Step 9: Drop configured_object_types table + logger.info('Migration: Dropping configured_object_types table...'); + await txDb.execute(`DROP TABLE IF EXISTS configured_object_types`); + logger.info('Migration: Dropped configured_object_types table'); + }); + + logger.info('Migration: Migration to normalized schema structure completed successfully'); + } catch (error) { + logger.error('Migration: Failed to migrate to normalized schema structure', error); + throw error; + } +} diff --git a/backend/src/services/database/migrations.ts b/backend/src/services/database/migrations.ts index d5bbce8..51ee647 100644 --- a/backend/src/services/database/migrations.ts +++ b/backend/src/services/database/migrations.ts @@ -6,7 +6,7 @@ import { logger } from '../logger.js'; import type { DatabaseAdapter } from './interface.js'; -import { createDatabaseAdapter } from './factory.js'; +import { getDatabaseAdapter } from './singleton.js'; // @ts-ignore - bcrypt doesn't have proper ESM types import bcrypt from 'bcrypt'; @@ -351,6 +351,7 @@ async function seedInitialData(db: DatabaseAdapter): Promise { { name: 'manage_users', description: 'Manage users and their roles', resource: 'users' }, { name: 'manage_roles', description: 'Manage roles and permissions', resource: 'roles' }, { name: 'manage_settings', description: 'Manage application settings', resource: 'settings' }, + { name: 'admin', description: 'Full administrative access (debug, sync, all operations)', resource: 'admin' }, ]; for (const perm of permissions) { @@ -424,6 +425,43 @@ async function seedInitialData(db: DatabaseAdapter): Promise { if (adminRole) { roleIds['administrator'] = adminRole.id; } + + // Ensure "admin" permission exists (may have been added after initial setup) + const adminPerm = await db.queryOne<{ id: number }>( + 'SELECT id FROM permissions WHERE name = ?', + ['admin'] + ); + + if (!adminPerm) { + // Add missing "admin" permission + await db.execute( + 'INSERT INTO permissions (name, description, resource) VALUES (?, ?, ?)', + ['admin', 'Full administrative access (debug, sync, all operations)', 'admin'] + ); + logger.info('Added missing "admin" permission'); + } + + // Ensure administrator role has "admin" permission + // Get admin permission ID (either existing or newly created) + const adminPermId = adminPerm?.id || (await db.queryOne<{ id: number }>( + 'SELECT id FROM permissions WHERE name = ?', + ['admin'] + ))?.id; + + if (adminRole && adminPermId) { + const hasAdminPerm = await db.queryOne<{ role_id: number }>( + 'SELECT role_id FROM role_permissions WHERE role_id = ? AND permission_id = ?', + [adminRole.id, adminPermId] + ); + + if (!hasAdminPerm) { + await db.execute( + 'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)', + [adminRole.id, adminPermId] + ); + logger.info('Assigned "admin" permission to administrator role'); + } + } } // Create initial admin user if ADMIN_EMAIL and ADMIN_PASSWORD are set @@ -489,7 +527,8 @@ async function seedInitialData(db: DatabaseAdapter): Promise { * Main migration function */ export async function runMigrations(): Promise { - const db = createDatabaseAdapter(); + // Use shared database adapter singleton + const db = getDatabaseAdapter(); try { logger.info('Running database migrations...'); @@ -526,7 +565,7 @@ let authDatabaseAdapter: DatabaseAdapter | null = null; export function getAuthDatabase(): DatabaseAdapter { if (!authDatabaseAdapter) { // Create adapter with allowClose=false so it won't be closed after operations - authDatabaseAdapter = createDatabaseAdapter(undefined, undefined, false); + authDatabaseAdapter = getDatabaseAdapter(); } return authDatabaseAdapter; } diff --git a/backend/src/services/database/normalized-schema-init.ts b/backend/src/services/database/normalized-schema-init.ts new file mode 100644 index 0000000..5ce8703 --- /dev/null +++ b/backend/src/services/database/normalized-schema-init.ts @@ -0,0 +1,43 @@ +/** + * Database Schema Initialization + * + * Ensures normalized EAV schema is initialized before services use it. + */ + +import { getDatabaseAdapter } from './singleton.js'; +import { NORMALIZED_SCHEMA_POSTGRES, NORMALIZED_SCHEMA_SQLITE } from './normalized-schema.js'; +import { logger } from '../logger.js'; + +let initialized = false; +let initializationPromise: Promise | null = null; + +/** + * Ensure database schema is initialized + */ +export async function ensureSchemaInitialized(): Promise { + if (initialized) return; + if (initializationPromise) { + await initializationPromise; + return; + } + + initializationPromise = (async () => { + try { + // Use shared database adapter singleton + const db = getDatabaseAdapter(); + const isPostgres = db.isPostgres === true; + + // Execute schema + const schema = isPostgres ? NORMALIZED_SCHEMA_POSTGRES : NORMALIZED_SCHEMA_SQLITE; + await db.exec(schema); + + logger.info(`Database schema initialized (${isPostgres ? 'PostgreSQL' : 'SQLite'})`); + initialized = true; + } catch (error) { + logger.error('Failed to initialize database schema', error); + throw error; + } + })(); + + await initializationPromise; +} diff --git a/backend/src/services/database/normalized-schema.ts b/backend/src/services/database/normalized-schema.ts new file mode 100644 index 0000000..4726c2e --- /dev/null +++ b/backend/src/services/database/normalized-schema.ts @@ -0,0 +1,329 @@ +/** + * Normalized Database Schema + * + * Generic, schema-agnostic normalized structure for CMDB data. + * Works with any Jira Assets configuration. + */ + +export const NORMALIZED_SCHEMA_POSTGRES = ` +-- ============================================================================= +-- Schemas (Jira Assets schemas) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS schemas ( + id SERIAL PRIMARY KEY, + jira_schema_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + object_schema_key TEXT, + status TEXT, + description TEXT, + search_enabled BOOLEAN NOT NULL DEFAULT TRUE, + discovered_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- ============================================================================= +-- Object Types (discovered from Jira schema, with schema relation and enabled flag) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS object_types ( + id SERIAL PRIMARY KEY, + schema_id INTEGER NOT NULL REFERENCES schemas(id) ON DELETE CASCADE, + jira_type_id INTEGER NOT NULL, + type_name TEXT NOT NULL, + display_name TEXT NOT NULL, + description TEXT, + sync_priority INTEGER DEFAULT 0, + object_count INTEGER DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + discovered_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(schema_id, jira_type_id), + UNIQUE(schema_id, type_name) +); + +-- ============================================================================= +-- Attributes (discovered from Jira schema) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS attributes ( + id SERIAL PRIMARY KEY, + jira_attr_id INTEGER NOT NULL, + object_type_name TEXT NOT NULL, + attr_name TEXT NOT NULL, + field_name TEXT NOT NULL, + attr_type TEXT NOT NULL, + is_multiple BOOLEAN NOT NULL DEFAULT FALSE, + is_editable BOOLEAN NOT NULL DEFAULT TRUE, + is_required BOOLEAN NOT NULL DEFAULT FALSE, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + reference_type_name TEXT, + description TEXT, + position INTEGER DEFAULT 0, + discovered_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(jira_attr_id, object_type_name) +); + +-- ============================================================================= +-- Objects (minimal metadata) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS objects ( + id TEXT PRIMARY KEY, + object_key TEXT NOT NULL UNIQUE, + object_type_name TEXT NOT NULL, + label TEXT NOT NULL, + jira_updated_at TIMESTAMP, + jira_created_at TIMESTAMP, + cached_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- ============================================================================= +-- Attribute Values (EAV pattern - generic for all types) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS attribute_values ( + id SERIAL PRIMARY KEY, + object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + attribute_id INTEGER NOT NULL REFERENCES attributes(id) ON DELETE CASCADE, + text_value TEXT, + number_value NUMERIC, + boolean_value BOOLEAN, + date_value DATE, + datetime_value TIMESTAMP, + reference_object_id TEXT, + reference_object_key TEXT, + reference_object_label TEXT, + array_index INTEGER DEFAULT 0, + UNIQUE(object_id, attribute_id, array_index) +); + +-- ============================================================================= +-- Relationships (enhanced existing table) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS object_relations ( + id SERIAL PRIMARY KEY, + source_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + target_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + attribute_id INTEGER NOT NULL REFERENCES attributes(id) ON DELETE CASCADE, + source_type TEXT NOT NULL, + target_type TEXT NOT NULL, + UNIQUE(source_id, target_id, attribute_id) +); + +-- ============================================================================= +-- Schema Mappings (object type -> schema ID) - DEPRECATED +-- ============================================================================= +CREATE TABLE IF NOT EXISTS schema_mappings ( + object_type_name TEXT PRIMARY KEY, + schema_id TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + + +-- ============================================================================= +-- Sync Metadata (unchanged) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS sync_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- ============================================================================= +-- Indexes for Performance +-- ============================================================================= + +-- Schema indexes +CREATE INDEX IF NOT EXISTS idx_schemas_jira_schema_id ON schemas(jira_schema_id); +CREATE INDEX IF NOT EXISTS idx_schemas_name ON schemas(name); +CREATE INDEX IF NOT EXISTS idx_schemas_search_enabled ON schemas(search_enabled); + +-- Object type indexes (for schema queries) +CREATE INDEX IF NOT EXISTS idx_object_types_type_name ON object_types(type_name); +CREATE INDEX IF NOT EXISTS idx_object_types_jira_id ON object_types(jira_type_id); +CREATE INDEX IF NOT EXISTS idx_object_types_schema_id ON object_types(schema_id); +CREATE INDEX IF NOT EXISTS idx_object_types_sync_priority ON object_types(sync_priority); +CREATE INDEX IF NOT EXISTS idx_object_types_enabled ON object_types(enabled); +CREATE INDEX IF NOT EXISTS idx_object_types_schema_enabled ON object_types(schema_id, enabled); + +-- Object indexes +CREATE INDEX IF NOT EXISTS idx_objects_type ON objects(object_type_name); +CREATE INDEX IF NOT EXISTS idx_objects_key ON objects(object_key); +CREATE INDEX IF NOT EXISTS idx_objects_label ON objects(label); +CREATE INDEX IF NOT EXISTS idx_objects_cached_at ON objects(cached_at); + +-- Attribute indexes +CREATE INDEX IF NOT EXISTS idx_attributes_type ON attributes(object_type_name); +CREATE INDEX IF NOT EXISTS idx_attributes_field ON attributes(field_name); +CREATE INDEX IF NOT EXISTS idx_attributes_jira_id ON attributes(jira_attr_id); +CREATE INDEX IF NOT EXISTS idx_attributes_type_field ON attributes(object_type_name, field_name); + +-- Attribute value indexes (critical for query performance) +CREATE INDEX IF NOT EXISTS idx_attr_values_object ON attribute_values(object_id); +CREATE INDEX IF NOT EXISTS idx_attr_values_attr ON attribute_values(attribute_id); +CREATE INDEX IF NOT EXISTS idx_attr_values_text ON attribute_values(text_value) WHERE text_value IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_attr_values_number ON attribute_values(number_value) WHERE number_value IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_attr_values_reference ON attribute_values(reference_object_id) WHERE reference_object_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_attr_values_composite_text ON attribute_values(attribute_id, text_value) WHERE text_value IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_attr_values_composite_ref ON attribute_values(attribute_id, reference_object_id) WHERE reference_object_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_attr_values_object_attr ON attribute_values(object_id, attribute_id); + +-- Relation indexes +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_attr ON object_relations(attribute_id); +CREATE INDEX IF NOT EXISTS idx_relations_source_type ON object_relations(source_id, source_type); +CREATE INDEX IF NOT EXISTS idx_relations_target_type ON object_relations(target_id, target_type); + +-- Schema indexes +CREATE INDEX IF NOT EXISTS idx_schemas_jira_schema_id ON schemas(jira_schema_id); +CREATE INDEX IF NOT EXISTS idx_schemas_name ON schemas(name); + +-- Schema mapping indexes +CREATE INDEX IF NOT EXISTS idx_schema_mappings_type ON schema_mappings(object_type_name); +CREATE INDEX IF NOT EXISTS idx_schema_mappings_schema ON schema_mappings(schema_id); +CREATE INDEX IF NOT EXISTS idx_schema_mappings_enabled ON schema_mappings(enabled); +`; + +export const NORMALIZED_SCHEMA_SQLITE = ` +-- ============================================================================= +-- SQLite version (for development/testing) +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS schemas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + jira_schema_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + object_schema_key TEXT, + status TEXT, + description TEXT, + search_enabled INTEGER NOT NULL DEFAULT 1, + discovered_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS object_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + schema_id INTEGER NOT NULL, + jira_type_id INTEGER NOT NULL, + type_name TEXT NOT NULL, + display_name TEXT NOT NULL, + description TEXT, + sync_priority INTEGER DEFAULT 0, + object_count INTEGER DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 0, + discovered_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(schema_id, jira_type_id), + UNIQUE(schema_id, type_name), + FOREIGN KEY (schema_id) REFERENCES schemas(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS attributes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + jira_attr_id INTEGER NOT NULL, + object_type_name TEXT NOT NULL, + attr_name TEXT NOT NULL, + field_name TEXT NOT NULL, + attr_type TEXT NOT NULL, + is_multiple INTEGER NOT NULL DEFAULT 0, + is_editable INTEGER NOT NULL DEFAULT 1, + is_required INTEGER NOT NULL DEFAULT 0, + is_system INTEGER NOT NULL DEFAULT 0, + reference_type_name TEXT, + description TEXT, + position INTEGER DEFAULT 0, + discovered_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(jira_attr_id, object_type_name) +); + +CREATE TABLE IF NOT EXISTS objects ( + id TEXT PRIMARY KEY, + object_key TEXT NOT NULL UNIQUE, + object_type_name TEXT NOT NULL, + label TEXT NOT NULL, + jira_updated_at TEXT, + jira_created_at TEXT, + cached_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS attribute_values ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + object_id TEXT NOT NULL, + attribute_id INTEGER NOT NULL, + text_value TEXT, + number_value REAL, + boolean_value INTEGER, + date_value TEXT, + datetime_value TEXT, + reference_object_id TEXT, + reference_object_key TEXT, + reference_object_label TEXT, + array_index INTEGER DEFAULT 0, + UNIQUE(object_id, attribute_id, array_index), + FOREIGN KEY (object_id) REFERENCES objects(id) ON DELETE CASCADE, + FOREIGN KEY (attribute_id) REFERENCES attributes(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS object_relations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + attribute_id INTEGER NOT NULL, + source_type TEXT NOT NULL, + target_type TEXT NOT NULL, + UNIQUE(source_id, target_id, attribute_id), + FOREIGN KEY (source_id) REFERENCES objects(id) ON DELETE CASCADE, + FOREIGN KEY (target_id) REFERENCES objects(id) ON DELETE CASCADE, + FOREIGN KEY (attribute_id) REFERENCES attributes(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS schema_mappings ( + object_type_name TEXT PRIMARY KEY, + schema_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + + +CREATE TABLE IF NOT EXISTS sync_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_objects_type ON objects(object_type_name); +CREATE INDEX IF NOT EXISTS idx_objects_key ON objects(object_key); +CREATE INDEX IF NOT EXISTS idx_objects_label ON objects(label); +CREATE INDEX IF NOT EXISTS idx_attributes_type ON attributes(object_type_name); +CREATE INDEX IF NOT EXISTS idx_attributes_field ON attributes(field_name); +CREATE INDEX IF NOT EXISTS idx_attributes_jira_id ON attributes(jira_attr_id); +CREATE INDEX IF NOT EXISTS idx_attributes_type_field ON attributes(object_type_name, field_name); +CREATE INDEX IF NOT EXISTS idx_attr_values_object ON attribute_values(object_id); +CREATE INDEX IF NOT EXISTS idx_attr_values_attr ON attribute_values(attribute_id); +CREATE INDEX IF NOT EXISTS idx_attr_values_text ON attribute_values(text_value); +CREATE INDEX IF NOT EXISTS idx_attr_values_number ON attribute_values(number_value); +CREATE INDEX IF NOT EXISTS idx_attr_values_reference ON attribute_values(reference_object_id); +CREATE INDEX IF NOT EXISTS idx_attr_values_object_attr ON attribute_values(object_id, attribute_id); +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_attr ON object_relations(attribute_id); + +-- Schema indexes +CREATE INDEX IF NOT EXISTS idx_schemas_jira_schema_id ON schemas(jira_schema_id); +CREATE INDEX IF NOT EXISTS idx_schemas_name ON schemas(name); +CREATE INDEX IF NOT EXISTS idx_schemas_search_enabled ON schemas(search_enabled); + +-- Object type indexes +CREATE INDEX IF NOT EXISTS idx_object_types_type_name ON object_types(type_name); +CREATE INDEX IF NOT EXISTS idx_object_types_jira_id ON object_types(jira_type_id); +CREATE INDEX IF NOT EXISTS idx_object_types_schema_id ON object_types(schema_id); +CREATE INDEX IF NOT EXISTS idx_object_types_sync_priority ON object_types(sync_priority); +CREATE INDEX IF NOT EXISTS idx_object_types_enabled ON object_types(enabled); +CREATE INDEX IF NOT EXISTS idx_object_types_schema_enabled ON object_types(schema_id, enabled); + +-- Schema mapping indexes +CREATE INDEX IF NOT EXISTS idx_schema_mappings_type ON schema_mappings(object_type_name); +CREATE INDEX IF NOT EXISTS idx_schema_mappings_schema ON schema_mappings(schema_id); +CREATE INDEX IF NOT EXISTS idx_schema_mappings_enabled ON schema_mappings(enabled); +`; diff --git a/backend/src/services/database/postgresAdapter.ts b/backend/src/services/database/postgresAdapter.ts index 2010a06..66d7c82 100644 --- a/backend/src/services/database/postgresAdapter.ts +++ b/backend/src/services/database/postgresAdapter.ts @@ -9,6 +9,7 @@ import { logger } from '../logger.js'; import type { DatabaseAdapter } from './interface.js'; export class PostgresAdapter implements DatabaseAdapter { + public readonly isPostgres = true; // Indicates this is PostgreSQL private pool: Pool; private connectionString: string; private isClosed: boolean = false; @@ -72,6 +73,7 @@ export class PostgresAdapter implements DatabaseAdapter { // Create a transaction-scoped adapter const transactionAdapter: DatabaseAdapter = { + isPostgres: true, // Indicates this is PostgreSQL query: async (sql: string, params?: any[]) => { const convertedSql = this.convertPlaceholders(sql); const result = await client.query(convertedSql, params); @@ -102,9 +104,16 @@ export class PostgresAdapter implements DatabaseAdapter { const result = await callback(transactionAdapter); await client.query('COMMIT'); return result; - } catch (error) { + } catch (error: any) { await client.query('ROLLBACK'); - logger.error('PostgreSQL transaction error:', error); + + // Don't log foreign key constraint errors as errors - they're expected and handled by caller + if (error?.code === '23503' || error?.message?.includes('foreign key constraint')) { + logger.debug('PostgreSQL transaction error (foreign key constraint - handled by caller):', error); + } else { + logger.error('PostgreSQL transaction error:', error); + } + throw error; } finally { client.release(); @@ -148,10 +157,13 @@ export class PostgresAdapter implements DatabaseAdapter { async getSizeBytes(): Promise { try { - const result = await this.query<{ size: number }>(` + const result = await this.query<{ size: number | string }>(` SELECT pg_database_size(current_database()) as size `); - return result[0]?.size || 0; + // PostgreSQL returns bigint as string, ensure we convert to number + const size = result[0]?.size; + if (!size) return 0; + return typeof size === 'string' ? parseInt(size, 10) : Number(size); } catch (error) { logger.error('PostgreSQL getSizeBytes error:', error); return 0; diff --git a/backend/src/services/database/singleton.ts b/backend/src/services/database/singleton.ts new file mode 100644 index 0000000..0ece50a --- /dev/null +++ b/backend/src/services/database/singleton.ts @@ -0,0 +1,28 @@ +/** + * Database Adapter Singleton + * + * Provides a shared database adapter instance to prevent multiple connections. + * All services should use this singleton instead of creating their own adapters. + */ + +import { createDatabaseAdapter } from './factory.js'; +import type { DatabaseAdapter } from './interface.js'; + +let dbAdapterInstance: DatabaseAdapter | null = null; + +/** + * Get the shared database adapter instance + */ +export function getDatabaseAdapter(): DatabaseAdapter { + if (!dbAdapterInstance) { + dbAdapterInstance = createDatabaseAdapter(undefined, undefined, false); // Don't allow close (singleton) + } + return dbAdapterInstance; +} + +/** + * Reset the singleton (for testing only) + */ +export function resetDatabaseAdapter(): void { + dbAdapterInstance = null; +} diff --git a/backend/src/services/jiraAssets.ts b/backend/src/services/jiraAssets.ts index e5f3853..180331b 100644 --- a/backend/src/services/jiraAssets.ts +++ b/backend/src/services/jiraAssets.ts @@ -98,6 +98,63 @@ class JiraAssetsService { private applicationFunctionCategoriesCache: Map | null = null; // Cache: Dynamics Factors with factors private dynamicsFactorsCache: Map | null = null; + + /** + * Get schema ID for an object type from database + * Returns the schema ID of the first enabled object type with the given type name + */ + private async getSchemaIdForObjectType(typeName: string): Promise { + try { + const { schemaConfigurationService } = await import('./schemaConfigurationService.js'); + const enabledTypes = await schemaConfigurationService.getEnabledObjectTypes(); + const type = enabledTypes.find(et => et.objectTypeName === typeName); + return type?.schemaId || null; + } catch (error) { + logger.warn(`JiraAssets: Failed to get schema ID for ${typeName}`, error); + return null; + } + } + + /** + * Get first available schema ID from database (fallback) + */ + private async getFirstSchemaId(): Promise { + try { + const { normalizedCacheStore } = await import('./normalizedCacheStore.js'); + const db = (normalizedCacheStore as any).db; + if (!db) return null; + + await db.ensureInitialized?.(); + const schemaRow = await db.queryOne<{ jira_schema_id: string }>( + `SELECT jira_schema_id FROM schemas ORDER BY jira_schema_id LIMIT 1` + ); + return schemaRow?.jira_schema_id || null; + } catch (error) { + logger.warn('JiraAssets: Failed to get first schema ID', error); + return null; + } + } + + /** + * Get all available schema IDs from database that are enabled for searching + */ + private async getAllSchemaIds(): Promise { + try { + const { normalizedCacheStore } = await import('./normalizedCacheStore.js'); + const db = (normalizedCacheStore as any).db; + if (!db) return []; + + await db.ensureInitialized?.(); + const schemaRows = await db.query<{ jira_schema_id: string }>( + `SELECT DISTINCT jira_schema_id FROM schemas WHERE search_enabled = ? ORDER BY jira_schema_id`, + [db.isPostgres ? true : 1] + ); + return schemaRows.map(row => row.jira_schema_id); + } catch (error) { + logger.warn('JiraAssets: Failed to get all schema IDs', error); + return []; + } + } // Cache: Complexity Factors with factors private complexityFactorsCache: Map | null = null; // Cache: Number of Users with factors @@ -109,7 +166,7 @@ class JiraAssetsService { // Cache: Team dashboard data private teamDashboardCache: { data: TeamDashboardData; timestamp: number } | null = null; private readonly TEAM_DASHBOARD_CACHE_TTL = 5 * 60 * 1000; // 5 minutes - // Cache: Dashboard stats + private dashboardStatsCache: { data: { totalApplications: number; @@ -121,6 +178,8 @@ class JiraAssetsService { timestamp: number } | null = null; private readonly DASHBOARD_STATS_CACHE_TTL = 3 * 60 * 1000; // 3 minutes + // Warming lock to prevent multiple simultaneous warming operations + private isWarming: boolean = false; constructor() { // Try both API paths - Insight (Data Center) and Assets (Cloud) @@ -742,7 +801,7 @@ class JiraAssetsService { try { await this.detectApiType(); - const url = `/object/${embeddedRefObj.id}?includeAttributes=true&includeAttributesDeep=1`; + const url = `/object/${embeddedRefObj.id}?includeAttributes=true&includeAttributesDeep=2`; const refObj = await this.request(url); if (refObj) { @@ -1337,6 +1396,12 @@ class JiraAssetsService { logger.info(`Searching applications with query: ${qlQuery}`); logger.debug(`Filters: ${JSON.stringify(filters)}`); + // Get schema ID for ApplicationComponent from database + const schemaId = await this.getSchemaIdForObjectType('ApplicationComponent') || await this.getFirstSchemaId(); + if (!schemaId) { + throw new Error('No schema ID available. Please configure object types in Schema Configuration settings.'); + } + let response: JiraAssetsSearchResponse; if (this.isDataCenter) { @@ -1347,8 +1412,8 @@ class JiraAssetsService { page: page.toString(), resultPerPage: pageSize.toString(), includeAttributes: 'true', - includeAttributesDeep: '1', - objectSchemaId: config.jiraSchemaId, + includeAttributesDeep: '2', + objectSchemaId: schemaId, }); logger.debug(`IQL request: /iql/objects?${params.toString()}`); @@ -1368,7 +1433,10 @@ class JiraAssetsService { '/aql/objects', { method: 'POST', - body: JSON.stringify(requestBody), + body: JSON.stringify({ + ...requestBody, + objectSchemaId: schemaId, + }), } ); } @@ -1665,10 +1733,29 @@ class JiraAssetsService { } } - async getReferenceObjects(objectType: string): Promise { + async getReferenceObjects(objectType: string, schemaId?: string): Promise { try { await this.detectApiType(); + // Get schema ID from mapping service if not provided + let effectiveSchemaId = schemaId; + if (!effectiveSchemaId) { + const { schemaMappingService } = await import('./schemaMappingService.js'); + const { OBJECT_TYPES } = await import('../generated/jira-schema.js'); + + // Find the typeName from the objectType (display name) + let typeName: string | null = null; + for (const [key, def] of Object.entries(OBJECT_TYPES)) { + if (def.name === objectType) { + typeName = key; + break; + } + } + + // Use typeName if found, otherwise fall back to objectType + effectiveSchemaId = await schemaMappingService.getSchemaId(typeName || objectType); + } + const qlQuery = `objectType = "${objectType}"`; let response: JiraAssetsSearchResponse; @@ -1678,8 +1765,8 @@ class JiraAssetsService { iql: qlQuery, resultPerPage: '200', includeAttributes: 'true', - includeAttributesDeep: '1', - objectSchemaId: config.jiraSchemaId, + includeAttributesDeep: '2', + objectSchemaId: effectiveSchemaId, }); response = await this.request( @@ -1695,6 +1782,7 @@ class JiraAssetsService { qlQuery, resultPerPage: 200, includeAttributes: true, + objectSchemaId: effectiveSchemaId, }), } ); @@ -1718,6 +1806,50 @@ class JiraAssetsService { } } + // Cache objects to normalized cache store for better performance + // This ensures objects are available in the database cache, not just in-memory + // This prevents individual API calls later when these objects are needed + if (response.objectEntries.length > 0) { + try { + const { OBJECT_TYPES } = await import('../generated/jira-schema.js'); + + // Find the CMDBObjectTypeName for this objectType + let typeName: CMDBObjectTypeName | null = null; + for (const [key, def] of Object.entries(OBJECT_TYPES)) { + if (def.name === objectType) { + typeName = key as CMDBObjectTypeName; + break; + } + } + + if (typeName) { + // Parse and cache objects in batch using the same business logic as sync + const { jiraAssetsClient } = await import('./jiraAssetsClient.js'); + const { normalizedCacheStore } = await import('./normalizedCacheStore.js'); + + const parsedObjects = await Promise.all( + response.objectEntries.map(obj => jiraAssetsClient.parseObject(obj)) + ); + const validParsedObjects = parsedObjects.filter((obj): obj is any => obj !== null); + + if (validParsedObjects.length > 0) { + // Batch upsert to cache (same as sync engine) + await normalizedCacheStore.batchUpsertObjects(typeName, validParsedObjects); + + // Extract and store relations for all objects (same as sync engine) + for (const obj of validParsedObjects) { + await normalizedCacheStore.extractAndStoreRelations(typeName, obj); + } + + logger.debug(`Cached ${validParsedObjects.length} ${objectType} objects to normalized cache with relations`); + } + } + } catch (error) { + // Don't fail if caching fails - this is an optimization + logger.debug(`Failed to cache ${objectType} objects to normalized cache`, error); + } + } + const results = response.objectEntries.map((obj) => { // Extract Description attribute (try multiple possible attribute names) // Use attrSchema for fallback lookup by attribute ID @@ -1926,6 +2058,12 @@ class JiraAssetsService { teamsById.set(team.objectId, team); } + // Get schema ID for ApplicationComponent + const schemaId = await this.getSchemaIdForObjectType('ApplicationComponent') || await this.getFirstSchemaId(); + if (!schemaId) { + throw new Error('No schema ID available. Please configure object types in Schema Configuration settings.'); + } + let response: JiraAssetsSearchResponse; if (this.isDataCenter) { @@ -1933,7 +2071,7 @@ class JiraAssetsService { iql, resultPerPage: '500', includeAttributes: 'true', - objectSchemaId: config.jiraSchemaId, + objectSchemaId: schemaId, }); response = await this.request( `/iql/objects?${params.toString()}` @@ -2081,8 +2219,52 @@ class JiraAssetsService { return null; } + // Check if there's already a pending request for this object (deduplicate concurrent requests) + // Check both objectIdToFetch and the alternate key (if both are provided) + const pendingRequest = this.pendingReferenceRequests.get(objectIdToFetch) + || (objectId && objectKey && objectId !== objectKey ? this.pendingReferenceRequests.get(objectKey) : undefined) + || (objectId && objectKey && objectId !== objectKey ? this.pendingReferenceRequests.get(objectId) : undefined); + + if (pendingRequest) { + logger.debug(`fetchEnrichedReferenceValue: Reusing pending request for ${objectKey} (${objectIdToFetch})`); + return pendingRequest; + } + + // Create a new fetch promise and store it in pending requests + // Store by both keys if they differ to catch all concurrent requests + const fetchPromise = this.doFetchEnrichedReferenceValue(objectKey, objectId, objectIdToFetch, cachedByKey, cachedById); + this.pendingReferenceRequests.set(objectIdToFetch, fetchPromise); + if (objectId && objectKey && objectId !== objectKey) { + // Also store by the alternate key to deduplicate requests that use the other key + this.pendingReferenceRequests.set(objectKey, fetchPromise); + this.pendingReferenceRequests.set(objectId, fetchPromise); + } + try { - const url = `/object/${objectIdToFetch}?includeAttributes=true&includeAttributesDeep=1`; + const result = await fetchPromise; + return result; + } finally { + // Remove from pending requests once done (success or failure) + this.pendingReferenceRequests.delete(objectIdToFetch); + if (objectId && objectKey && objectId !== objectKey) { + this.pendingReferenceRequests.delete(objectKey); + this.pendingReferenceRequests.delete(objectId); + } + } + } + + /** + * Internal method to actually fetch the enriched reference value (called by fetchEnrichedReferenceValue) + */ + private async doFetchEnrichedReferenceValue( + objectKey: string, + objectId: string | undefined, + objectIdToFetch: string, + cachedByKey: ReferenceValue | undefined, + cachedById: ReferenceValue | undefined + ): Promise { + try { + const url = `/object/${objectIdToFetch}?includeAttributes=true&includeAttributesDeep=2`; const refObj = await this.request(url); if (!refObj) { @@ -2170,6 +2352,12 @@ class JiraAssetsService { logger.info('Dashboard stats: Cache miss or expired, fetching fresh data'); try { + // Get schema ID for ApplicationComponent + const schemaId = await this.getSchemaIdForObjectType('ApplicationComponent') || await this.getFirstSchemaId(); + if (!schemaId) { + throw new Error('No schema ID available. Please configure object types in Schema Configuration settings.'); + } + const allAppsQuery = 'objectType = "Application Component" AND Status != "Closed"'; // First, get total count with a single query @@ -2179,7 +2367,7 @@ class JiraAssetsService { iql: allAppsQuery, resultPerPage: '1', includeAttributes: 'true', - objectSchemaId: config.jiraSchemaId, + objectSchemaId: schemaId, }); totalCountResponse = await this.request( `/iql/objects?${params.toString()}` @@ -2193,6 +2381,7 @@ class JiraAssetsService { qlQuery: allAppsQuery, resultPerPage: 1, includeAttributes: true, + objectSchemaId: schemaId, }), } ); @@ -2222,7 +2411,7 @@ class JiraAssetsService { iql: sampleQuery, resultPerPage: '1', includeAttributes: 'true', - objectSchemaId: config.jiraSchemaId, + objectSchemaId: schemaId, }); sampleResponse = await this.request( `/iql/objects?${sampleParams.toString()}` @@ -2232,14 +2421,15 @@ class JiraAssetsService { '/aql/objects', { method: 'POST', - body: JSON.stringify({ - qlQuery: sampleQuery, - resultPerPage: 1, - includeAttributes: true, - }), - } - ); + body: JSON.stringify({ + qlQuery: sampleQuery, + resultPerPage: 1, + includeAttributes: true, + objectSchemaId: schemaId, + }), } + ); + } if (sampleResponse.objectEntries && sampleResponse.objectEntries.length > 0) { const firstObj = sampleResponse.objectEntries[0]; @@ -2273,7 +2463,7 @@ class JiraAssetsService { resultPerPage: pageSize.toString(), pageNumber: currentPage.toString(), includeAttributes: 'true', - objectSchemaId: config.jiraSchemaId, + objectSchemaId: schemaId, }); batchResponse = await this.request( `/iql/objects?${params.toString()}` @@ -2288,6 +2478,7 @@ class JiraAssetsService { resultPerPage: pageSize, pageNumber: currentPage, includeAttributes: true, + objectSchemaId: schemaId, }), } ); @@ -2386,7 +2577,7 @@ class JiraAssetsService { iql: classifiedQuery, resultPerPage: '1', includeAttributes: 'true', - objectSchemaId: config.jiraSchemaId, + objectSchemaId: schemaId, }); classifiedResponse = await this.request( `/iql/objects?${params.toString()}` @@ -2484,13 +2675,19 @@ class JiraAssetsService { if (this.attributeSchemaCache.has(objectTypeName)) { attrSchema = this.attributeSchemaCache.get(objectTypeName); } else { + // Get schema ID for ApplicationComponent + const schemaId = await this.getSchemaIdForObjectType('ApplicationComponent') || await this.getFirstSchemaId(); + if (!schemaId) { + throw new Error('No schema ID available. Please configure object types in Schema Configuration settings.'); + } + const testParams = new URLSearchParams({ iql: qlQuery, page: '1', resultPerPage: '1', includeAttributes: 'true', - includeAttributesDeep: '1', - objectSchemaId: config.jiraSchemaId, + includeAttributesDeep: '2', + objectSchemaId: schemaId, }); const testResponse = await this.request( `/iql/objects?${testParams.toString()}` @@ -2510,6 +2707,12 @@ class JiraAssetsService { this.ensureFactorCaches(), ]); + // Get schema ID for ApplicationComponent + const schemaId = await this.getSchemaIdForObjectType('ApplicationComponent') || await this.getFirstSchemaId(); + if (!schemaId) { + throw new Error('No schema ID available. Please configure object types in Schema Configuration settings.'); + } + // Get total count let firstResponse: JiraAssetsSearchResponse; if (this.isDataCenter) { @@ -2518,8 +2721,8 @@ class JiraAssetsService { page: '1', resultPerPage: '1', includeAttributes: 'true', - includeAttributesDeep: '1', - objectSchemaId: config.jiraSchemaId, + includeAttributesDeep: '2', + objectSchemaId: schemaId, }); firstResponse = await this.request( `/iql/objects?${params.toString()}` @@ -2563,8 +2766,8 @@ class JiraAssetsService { page: pageNum.toString(), resultPerPage: batchSize.toString(), includeAttributes: 'true', - includeAttributesDeep: '1', - objectSchemaId: config.jiraSchemaId, + includeAttributesDeep: '2', + objectSchemaId: schemaId, }); response = await this.request( `/iql/objects?${params.toString()}` @@ -2982,84 +3185,159 @@ class JiraAssetsService { 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 API call - Query: "${query}", URL: ${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}`); + // Get all available schema IDs to search across all schemas + const schemaIds = await this.getAllSchemaIds(); + if (schemaIds.length === 0) { + // Fallback to first schema if no schemas found + const fallbackSchemaId = await this.getFirstSchemaId(); + if (!fallbackSchemaId) { + throw new Error('No schema ID available. Please configure object types in Schema Configuration settings.'); + } + schemaIds.push(fallbackSchemaId); } - 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<{ + logger.info(`CMDB search: Searching across ${schemaIds.length} schema(s) for query: "${query}"`); + + // Search each schema and collect results + const searchPromises = schemaIds.map(async (schemaId) => { + try { + // Use Insight AM search API endpoint (different from IQL) + const searchUrl = `${config.jiraHost}/rest/insight-am/1/search?` + + `schema=${schemaId}&` + + `criteria=${encodeURIComponent(query)}&` + + `criteriaType=FREETEXT&` + + `attributes=Key,Object+Type,Label,Name,Description,Status&` + + `offset=0&limit=${limit}`; + + logger.debug(`CMDB search API call - Schema: ${schemaId}, Query: "${query}", URL: ${searchUrl}`); + + const response = await fetch(searchUrl, { + method: 'GET', + headers: this.headers, + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.warn(`CMDB search failed for schema ${schemaId}: ${response.status} - ${errorText}`); + return null; // Return null for failed schemas, we'll continue with others + } + + 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; + }>; + }; + + return { + schemaId, + results: data.results || [], + objectTypes: data.objectTypes || [], + metadata: data.metadata, + }; + } catch (error) { + logger.warn(`CMDB search error for schema ${schemaId}:`, error); + return null; // Return null for failed schemas, we'll continue with others + } + }); + + // Wait for all searches to complete + const searchResults = await Promise.all(searchPromises); + + // Merge results from all schemas + const allResults: Array<{ + id: number; + key: string; + label: string; + objectTypeId: number; + avatarUrl?: string; + attributes: Array<{ id: number; name: string; - iconUrl?: string; + objectTypeAttributeId: number; + values: unknown[]; }>; - }; + }> = []; + const objectTypeMap = new Map(); + let totalCount = 0; - // 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 || [], - })), - })); + for (const result of searchResults) { + if (!result) continue; // Skip failed schemas + + // Add results (avoid duplicates by key) + const existingKeys = new Set(allResults.map(r => r.key)); + for (const item of result.results) { + if (!existingKeys.has(item.key)) { + allResults.push({ + id: item.id, + key: item.key, + label: item.label, + objectTypeId: item.objectTypeId, + avatarUrl: item.avatarUrl, + attributes: (item.attributes || []).map((attr) => ({ + id: attr.id, + name: attr.name, + objectTypeAttributeId: attr.objectTypeAttributeId, + values: attr.values || [], + })), + }); + existingKeys.add(item.key); + } + } + + // Merge object types (avoid duplicates by id) + for (const ot of result.objectTypes) { + if (!objectTypeMap.has(ot.id)) { + objectTypeMap.set(ot.id, { + id: ot.id, + name: ot.name, + iconUrl: ot.iconUrl, + }); + } + } + + // Sum up total counts + if (result.metadata?.total) { + totalCount += result.metadata.total; + } + } + + // Apply limit to merged results + const limitedResults = allResults.slice(0, limit); + + logger.info(`CMDB search: Found ${limitedResults.length} results (${allResults.length} total before limit) across ${schemaIds.length} schema(s)`); return { - metadata: data.metadata || { - count: transformedResults.length, + metadata: { + count: limitedResults.length, offset: 0, limit, - total: transformedResults.length, - criteria: { query, type: 'FREETEXT', schema: parseInt(config.jiraSchemaId, 10) }, + total: totalCount || limitedResults.length, + criteria: { query, type: 'FREETEXT', schema: schemaIds.length > 0 ? parseInt(schemaIds[0], 10) : 0 }, }, - objectTypes: (data.objectTypes || []).map((ot) => ({ - id: ot.id, - name: ot.name, - iconUrl: ot.iconUrl, - })), - results: transformedResults, + objectTypes: Array.from(objectTypeMap.values()), + results: limitedResults, }; } catch (error) { logger.error('CMDB search failed', error); @@ -3099,12 +3377,18 @@ class JiraAssetsService { let response: JiraAssetsSearchResponse; if (this.isDataCenter) { + // Get schema ID for the object type (or first available) + const schemaId = await this.getSchemaIdForObjectType(objectType) || await this.getFirstSchemaId(); + if (!schemaId) { + throw new Error('No schema ID available. Please configure object types in Schema Configuration settings.'); + } + const params = new URLSearchParams({ iql: iqlQuery, resultPerPage: '100', includeAttributes: 'true', - includeAttributesDeep: '1', - objectSchemaId: config.jiraSchemaId, + includeAttributesDeep: '2', + objectSchemaId: schemaId, }); response = await this.request( @@ -3206,37 +3490,56 @@ class JiraAssetsService { } /** - * Pre-warm the team dashboard cache in background - * This is called on server startup so users don't experience slow first load + * Pre-warm the full cache using sync engine + * This is more efficient than pre-warming just the team dashboard + * as it syncs all object types and their relations + * Checks cache status first to avoid unnecessary syncs */ - async preWarmTeamDashboardCache(): Promise { + async preWarmFullCache(): Promise { + // Prevent multiple simultaneous warming operations + if (this.isWarming) { + logger.debug('Cache warming already in progress, skipping duplicate request'); + return; + } + try { - // Only pre-warm if cache is empty - if (this.teamDashboardCache) { - logger.info('Team dashboard cache already warm, skipping pre-warm'); + this.isWarming = true; + + // Check if schema configuration is complete before attempting sync + const { schemaConfigurationService } = await import('./schemaConfigurationService.js'); + const isConfigured = await schemaConfigurationService.isConfigurationComplete(); + if (!isConfigured) { + logger.info('Schema configuration not complete, skipping automatic cache pre-warming. Please configure object types in settings first.'); return; } - logger.info('Pre-warming team dashboard cache in background...'); + // Check if cache is already warm before syncing + const { normalizedCacheStore } = await import('./normalizedCacheStore.js'); + const isWarm = await normalizedCacheStore.isWarm(); + + if (isWarm) { + logger.info('Cache is already warm, skipping pre-warm'); + return; + } + + logger.info('Pre-warming full cache in background using sync engine...'); const startTime = Date.now(); - // Fetch with default excluded statuses (which is what most users will see) - await this.getTeamDashboardData(['Closed', 'Deprecated']); + const { syncEngine } = await import('./syncEngine.js'); + await syncEngine.fullSync(); const duration = Date.now() - startTime; - logger.info(`Team dashboard cache pre-warmed in ${duration}ms`); + logger.info(`Full cache pre-warmed in ${duration}ms`); } catch (error) { - logger.error('Failed to pre-warm team dashboard cache', error); + logger.error('Failed to pre-warm full cache', error); // Don't throw - pre-warming is optional + } finally { + this.isWarming = false; } } } 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 +// Note: Pre-warm cache removed - all syncs must be triggered manually from GUI +// The preWarmFullCache() method is still available for manual API calls but won't auto-start diff --git a/backend/src/services/jiraAssetsClient.ts b/backend/src/services/jiraAssetsClient.ts index c2c7937..4a0938b 100644 --- a/backend/src/services/jiraAssetsClient.ts +++ b/backend/src/services/jiraAssetsClient.ts @@ -7,9 +7,12 @@ 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 { schemaCacheService } from './schemaCacheService.js'; +import type { CMDBObject, ObjectReference } from '../generated/jira-types.js'; import type { JiraAssetsObject, JiraAssetsAttribute, JiraAssetsSearchResponse } from '../types/index.js'; +import type { ObjectEntry, ObjectAttribute, ObjectAttributeValue, ReferenceValue, ConfluenceValue } from '../domain/jiraAssetsPayload.js'; +import { isReferenceValue, isSimpleValue, hasAttributes } from '../domain/jiraAssetsPayload.js'; +import { normalizedCacheStore } from './normalizedCacheStore.js'; // ============================================================================= // Types @@ -31,14 +34,39 @@ export interface JiraUpdatePayload { }>; } -// Map from Jira object type ID to our type name -const TYPE_ID_TO_NAME: Record = {}; -const JIRA_NAME_TO_TYPE: Record = {}; +// Lookup maps - will be populated dynamically from database schema +let TYPE_ID_TO_NAME: Record = {}; +let JIRA_NAME_TO_TYPE: Record = {}; +let OBJECT_TYPES_CACHE: 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; +/** + * Initialize lookup maps from database schema + */ +async function initializeLookupMaps(): Promise { + try { + const schema = await schemaCacheService.getSchema(); + OBJECT_TYPES_CACHE = {}; + TYPE_ID_TO_NAME = {}; + JIRA_NAME_TO_TYPE = {}; + + for (const [typeName, typeDef] of Object.entries(schema.objectTypes)) { + OBJECT_TYPES_CACHE[typeName] = { + jiraTypeId: typeDef.jiraTypeId, + name: typeDef.name, + attributes: typeDef.attributes.map(attr => ({ + jiraId: attr.jiraId, + name: attr.name, + fieldName: attr.fieldName, + type: attr.type, + isMultiple: attr.isMultiple, + })), + }; + TYPE_ID_TO_NAME[typeDef.jiraTypeId] = typeName; + JIRA_NAME_TO_TYPE[typeDef.name] = typeName; + } + } catch (error) { + logger.error('JiraAssetsClient: Failed to initialize lookup maps', error); + } } // ============================================================================= @@ -181,7 +209,8 @@ class JiraAssetsClient { try { await this.detectApiType(); - const response = await fetch(`${this.baseUrl}/objectschema/${config.jiraSchemaId}`, { + // Test connection by fetching schemas list (no specific schema ID needed) + const response = await fetch(`${this.baseUrl}/objectschema/list`, { headers: this.getHeaders(false), // Read operation - uses service account token }); return response.ok; @@ -191,17 +220,35 @@ class JiraAssetsClient { } } - async getObject(objectId: string): Promise { + /** + * Get raw ObjectEntry for an object (for recursive processing) + */ + async getObjectEntry(objectId: string): Promise { try { // Include attributes and deep attributes to get full details of referenced objects (including descriptions) - const url = `/object/${objectId}?includeAttributes=true&includeAttributesDeep=1`; - return await this.request(url, {}, false); // Read operation + const url = `/object/${objectId}?includeAttributes=true&includeAttributesDeep=2`; + const entry = await this.request(url, {}, false) as unknown as ObjectEntry; // Read operation + return entry; } 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 entry ${objectId}`, error); + return null; + } + } + + async getObject(objectId: string): Promise { + try { + const entry = await this.getObjectEntry(objectId); + if (!entry) return null; + return this.adaptObjectEntryToJiraAssetsObject(entry); + } catch (error) { + if (error instanceof JiraObjectNotFoundError) { + throw error; + } logger.error(`JiraAssetsClient: Failed to get object ${objectId}`, error); return null; } @@ -210,11 +257,26 @@ class JiraAssetsClient { async searchObjects( iql: string, page: number = 1, - pageSize: number = 50 - ): Promise<{ objects: JiraAssetsObject[]; totalCount: number; hasMore: boolean }> { + pageSize: number = 50, + schemaId?: string + ): Promise<{ + objects: JiraAssetsObject[]; + totalCount: number; + hasMore: boolean; + referencedObjects?: Array<{ entry: ObjectEntry; typeName: string }>; + rawEntries?: ObjectEntry[]; // Raw ObjectEntry format for recursive processing + }> { await this.detectApiType(); - let response: JiraAssetsSearchResponse; + // Schema ID must be provided explicitly (no default from config) + if (!schemaId) { + throw new Error('Schema ID is required for searchObjects. Please provide schemaId parameter.'); + } + + const effectiveSchemaId = schemaId; + + // Use domain types for API requests + let payload: { objectEntries: ObjectEntry[]; totalCount?: number; totalFilterCount?: number; page?: number; pageSize?: number }; if (this.isDataCenter) { // Try modern AQL endpoint first @@ -224,10 +286,10 @@ class JiraAssetsClient { page: page.toString(), resultPerPage: pageSize.toString(), includeAttributes: 'true', - includeAttributesDeep: '1', - objectSchemaId: config.jiraSchemaId, + includeAttributesDeep: '2', + objectSchemaId: effectiveSchemaId, }); - response = await this.request(`/aql/objects?${params.toString()}`, {}, false); // Read operation + payload = await this.request<{ objectEntries: ObjectEntry[]; totalCount?: number; totalFilterCount?: number }>(`/aql/objects?${params.toString()}`, {}, false); // Read operation } catch (error) { // Fallback to deprecated IQL endpoint logger.warn(`JiraAssetsClient: AQL endpoint failed, falling back to IQL: ${error}`); @@ -236,51 +298,169 @@ class JiraAssetsClient { page: page.toString(), resultPerPage: pageSize.toString(), includeAttributes: 'true', - includeAttributesDeep: '1', - objectSchemaId: config.jiraSchemaId, + includeAttributesDeep: '2', + objectSchemaId: effectiveSchemaId, }); - response = await this.request(`/iql/objects?${params.toString()}`, {}, false); // Read operation + payload = await this.request<{ objectEntries: ObjectEntry[]; totalCount?: number; totalFilterCount?: number }>(`/iql/objects?${params.toString()}`, {}, false); // Read operation } } else { // Jira Cloud uses POST for AQL - response = await this.request('/aql/objects', { + payload = await this.request<{ objectEntries: ObjectEntry[]; totalCount?: number; totalFilterCount?: number }>('/aql/objects', { method: 'POST', body: JSON.stringify({ qlQuery: iql, page, resultPerPage: pageSize, includeAttributes: true, - includeAttributesDeep: 1, // Include attributes of referenced objects (e.g., descriptions) + includeAttributesDeep: 2, // Include attributes of referenced objects (e.g., descriptions) + objectSchemaId: effectiveSchemaId, }), }, false); // Read operation } + // Adapt to legacy response format for backward compatibility + const response = this.adaptAssetsPayloadToSearchResponse({ ...payload, page, pageSize }); const totalCount = response.totalFilterCount || response.totalCount || 0; const hasMore = response.objectEntries.length === pageSize && page * pageSize < totalCount; + // Note: referencedObjects extraction removed - recursive extraction now happens in storeObjectTree + // via extractNestedReferencedObjects, which processes the entire object tree recursively + return { objects: response.objectEntries || [], totalCount, hasMore, + referencedObjects: undefined, // No longer used - recursive extraction handles this + rawEntries: payload.objectEntries || [], // Return raw entries for recursive processing }; } + /** + * Recursively extract all nested referenced objects from an object entry + * This function traverses the object tree and extracts all referenced objects + * at any depth, preventing infinite loops with circular references. + * + * @param entry - The object entry to extract nested references from + * @param processedIds - Set of already processed object IDs (to prevent duplicates and circular refs) + * @param maxDepth - Maximum depth to traverse (default: 5) + * @param currentDepth - Current depth in the tree (default: 0) + * @returns Array of extracted referenced objects with their type names + */ + extractNestedReferencedObjects( + entry: ObjectEntry, + processedIds: Set, + maxDepth: number = 5, + currentDepth: number = 0 + ): Array<{ entry: ObjectEntry; typeName: string }> { + const result: Array<{ entry: ObjectEntry; typeName: string }> = []; + + // Prevent infinite recursion + if (currentDepth >= maxDepth) { + logger.debug(`JiraAssetsClient: [Recursive] Max depth (${maxDepth}) reached for object ${entry.objectKey || entry.id}`); + return result; + } + + const entryId = String(entry.id); + // Skip if already processed (handles circular references) + if (processedIds.has(entryId)) { + logger.debug(`JiraAssetsClient: [Recursive] Skipping already processed object ${entry.objectKey || entry.id} (circular reference detected)`); + return result; + } + processedIds.add(entryId); + + logger.debug(`JiraAssetsClient: [Recursive] Extracting nested references from ${entry.objectKey || entry.id} at depth ${currentDepth}`); + + // Initialize lookup maps if needed + if (Object.keys(TYPE_ID_TO_NAME).length === 0) { + // This is async, but we can't make this function async without breaking the call chain + // So we'll initialize it before calling this function + logger.warn('JiraAssetsClient: TYPE_ID_TO_NAME not initialized, type resolution may fail'); + } + + // Extract referenced objects from attributes + if (entry.attributes) { + for (const attr of entry.attributes) { + for (const val of attr.objectAttributeValues) { + if (isReferenceValue(val) && hasAttributes(val.referencedObject)) { + const refId = String(val.referencedObject.id); + + // Skip if already processed + if (processedIds.has(refId)) { + continue; + } + + const refTypeId = val.referencedObject.objectType?.id; + const refTypeName = TYPE_ID_TO_NAME[refTypeId] || + JIRA_NAME_TO_TYPE[val.referencedObject.objectType?.name]; + + if (refTypeName) { + logger.debug(`JiraAssetsClient: [Recursive] Found nested reference: ${val.referencedObject.objectKey || refId} of type ${refTypeName} at depth ${currentDepth + 1}`); + + // Add this referenced object to results + result.push({ + entry: val.referencedObject as ObjectEntry, + typeName: refTypeName, + }); + + // Recursively extract nested references from this referenced object + const nested = this.extractNestedReferencedObjects( + val.referencedObject as ObjectEntry, + processedIds, + maxDepth, + currentDepth + 1 + ); + result.push(...nested); + } else { + logger.debug(`JiraAssetsClient: [Recursive] Could not resolve type name for referenced object ${refId} (typeId: ${refTypeId}, typeName: ${val.referencedObject.objectType?.name})`); + } + } + } + } + } + + if (result.length > 0) { + logger.debug(`JiraAssetsClient: [Recursive] Extracted ${result.length} nested references from ${entry.objectKey || entry.id} at depth ${currentDepth}`); + } + + return result; + } + /** * Get the total count of objects for a specific type from Jira Assets * This is more efficient than fetching all objects when you only need the count + * @param typeName - Type name (from database, e.g. "ApplicationComponent") + * @param schemaId - Optional schema ID (if not provided, uses mapping or default) */ - async getObjectCount(typeName: CMDBObjectTypeName): Promise { - const typeDef = OBJECT_TYPES[typeName]; + async getObjectCount(typeName: string, schemaId?: string): Promise { + // Ensure lookup maps are initialized + if (Object.keys(OBJECT_TYPES_CACHE).length === 0) { + await initializeLookupMaps(); + } + + const typeDef = OBJECT_TYPES_CACHE[typeName]; if (!typeDef) { logger.warn(`JiraAssetsClient: Unknown type ${typeName}`); return 0; } try { + // Get schema ID from mapping service if not provided + let effectiveSchemaId = schemaId; + if (!effectiveSchemaId) { + const { schemaMappingService } = await import('./schemaMappingService.js'); + effectiveSchemaId = await schemaMappingService.getSchemaId(typeName); + } + + // Skip if no schema ID is available (object type not configured) + if (!effectiveSchemaId || effectiveSchemaId.trim() === '') { + logger.debug(`JiraAssetsClient: No schema ID configured for ${typeName}, returning 0`); + return 0; + } + const iql = `objectType = "${typeDef.name}"`; // Use pageSize=1 to minimize data transfer, we only need the totalCount - const result = await this.searchObjects(iql, 1, 1); - logger.debug(`JiraAssetsClient: ${typeName} has ${result.totalCount} objects in Jira Assets`); + const result = await this.searchObjects(iql, 1, 1, effectiveSchemaId); + logger.debug(`JiraAssetsClient: ${typeName} has ${result.totalCount} objects in Jira Assets (schema: ${effectiveSchemaId})`); return result.totalCount; } catch (error) { logger.error(`JiraAssetsClient: Failed to get count for ${typeName}`, error); @@ -289,29 +469,64 @@ class JiraAssetsClient { } async getAllObjectsOfType( - typeName: CMDBObjectTypeName, - batchSize: number = 40 - ): Promise { - const typeDef = OBJECT_TYPES[typeName]; - if (!typeDef) { - logger.warn(`JiraAssetsClient: Unknown type ${typeName}`); - return []; + typeName: string, + batchSize: number = 40, + schemaId?: string + ): Promise<{ + objects: JiraAssetsObject[]; + referencedObjects: Array<{ entry: ObjectEntry; typeName: string }>; + rawEntries?: ObjectEntry[]; // Raw ObjectEntry format for recursive processing + }> { + // If typeName is a display name (not in cache), use it directly for IQL query + // Otherwise, look up the type definition + let objectTypeName = typeName; + + // Try to find in cache first + if (Object.keys(OBJECT_TYPES_CACHE).length === 0) { + await initializeLookupMaps(); + } + + const typeDef = OBJECT_TYPES_CACHE[typeName]; + if (typeDef) { + objectTypeName = typeDef.name; // Use the Jira name from cache + } else { + // Type not in cache - assume typeName is already the Jira display name + logger.debug(`JiraAssetsClient: Type ${typeName} not in cache, using as display name directly`); + } + + // Get schema ID from mapping service if not provided + let effectiveSchemaId = schemaId; + if (!effectiveSchemaId) { + const { schemaMappingService } = await import('./schemaMappingService.js'); + effectiveSchemaId = await schemaMappingService.getSchemaId(typeName); + } + + if (!effectiveSchemaId) { + throw new Error(`No schema ID available for object type ${typeName}`); } const allObjects: JiraAssetsObject[] = []; + const rawEntries: ObjectEntry[] = []; // Store raw entries for recursive processing let page = 1; let hasMore = true; while (hasMore) { - const iql = `objectType = "${typeDef.name}"`; - const result = await this.searchObjects(iql, page, batchSize); + const iql = `objectType = "${objectTypeName}"`; + const result = await this.searchObjects(iql, page, batchSize, effectiveSchemaId); allObjects.push(...result.objects); + + // Collect raw entries for recursive processing + if (result.rawEntries) { + rawEntries.push(...result.rawEntries); + } + hasMore = result.hasMore; page++; } - logger.info(`JiraAssetsClient: Fetched ${allObjects.length} ${typeName} objects`); - return allObjects; + logger.info(`JiraAssetsClient: Fetched ${allObjects.length} ${typeName} objects from schema ${effectiveSchemaId} (raw entries: ${rawEntries.length})`); + // Note: referencedObjects no longer collected - recursive extraction in storeObjectTree handles nested objects + return { objects: allObjects, referencedObjects: [], rawEntries }; } async getUpdatedObjectsSince( @@ -357,38 +572,232 @@ class JiraAssetsClient { } } + // ========================================================================== + // Adapter Functions (temporary - for backward compatibility) + // ========================================================================== + + /** + * Adapt ObjectEntry from domain types to legacy JiraAssetsObject type + * This is a temporary adapter during migration + * Handles both ObjectEntry (domain) and legacy JiraAssetsObject formats + */ + adaptObjectEntryToJiraAssetsObject(entry: ObjectEntry | JiraAssetsObject | null): JiraAssetsObject | null { + if (!entry) return null; + + // Check if already in legacy format (has 'attributes' as array with JiraAssetsAttribute) + if ('attributes' in entry && Array.isArray(entry.attributes) && entry.attributes.length > 0 && 'objectTypeAttributeId' in entry.attributes[0] && !('id' in entry.attributes[0])) { + // Validate the legacy format object has required fields + const legacyObj = entry as JiraAssetsObject; + if (legacyObj.id === null || legacyObj.id === undefined) { + logger.warn(`JiraAssetsClient: Legacy object missing id. ObjectKey: ${legacyObj.objectKey}, Label: ${legacyObj.label}`); + return null; + } + if (!legacyObj.objectKey || !String(legacyObj.objectKey).trim()) { + logger.warn(`JiraAssetsClient: Legacy object missing objectKey. ID: ${legacyObj.id}, Label: ${legacyObj.label}`); + return null; + } + if (!legacyObj.label || !String(legacyObj.label).trim()) { + logger.warn(`JiraAssetsClient: Legacy object missing label. ID: ${legacyObj.id}, ObjectKey: ${legacyObj.objectKey}`); + return null; + } + return legacyObj; + } + + // Convert from ObjectEntry format + const domainEntry = entry as ObjectEntry; + + // Validate required fields before conversion + if (domainEntry.id === null || domainEntry.id === undefined) { + logger.warn(`JiraAssetsClient: ObjectEntry missing id. ObjectKey: ${domainEntry.objectKey}, Label: ${domainEntry.label}`); + return null; + } + + if (!domainEntry.objectKey || !String(domainEntry.objectKey).trim()) { + logger.warn(`JiraAssetsClient: ObjectEntry missing objectKey. ID: ${domainEntry.id}, Label: ${domainEntry.label}`); + return null; + } + + if (!domainEntry.label || !String(domainEntry.label).trim()) { + logger.warn(`JiraAssetsClient: ObjectEntry missing label. ID: ${domainEntry.id}, ObjectKey: ${domainEntry.objectKey}`); + return null; + } + + // Convert id - ensure it's a number + let objectId: number; + if (typeof domainEntry.id === 'string') { + const parsed = parseInt(domainEntry.id, 10); + if (isNaN(parsed)) { + logger.warn(`JiraAssetsClient: ObjectEntry id cannot be parsed as number: ${domainEntry.id}`); + return null; + } + objectId = parsed; + } else if (typeof domainEntry.id === 'number') { + objectId = domainEntry.id; + } else { + logger.warn(`JiraAssetsClient: ObjectEntry id has invalid type: ${typeof domainEntry.id}`); + return null; + } + + return { + id: objectId, + objectKey: String(domainEntry.objectKey).trim(), + label: String(domainEntry.label).trim(), + objectType: domainEntry.objectType, + created: domainEntry.created || new Date().toISOString(), + updated: domainEntry.updated || new Date().toISOString(), + attributes: (domainEntry.attributes || []).map(attr => this.adaptObjectAttributeToJiraAssetsAttribute(attr)), + }; + } + + /** + * Adapt ObjectAttribute from domain types to legacy JiraAssetsAttribute type + */ + private adaptObjectAttributeToJiraAssetsAttribute(attr: ObjectAttribute): JiraAssetsAttribute { + return { + objectTypeAttributeId: attr.objectTypeAttributeId, + objectTypeAttribute: undefined, // Not in domain type, will be populated from schema if needed + objectAttributeValues: attr.objectAttributeValues.map(val => this.adaptObjectAttributeValue(val)), + }; + } + + /** + * Adapt ObjectAttributeValue from domain types to legacy format + */ + private adaptObjectAttributeValue(val: ObjectAttributeValue): { + value?: string; + displayValue?: string; + referencedObject?: { id: number; objectKey: string; label: string }; + status?: { name: string }; + } { + if (isReferenceValue(val)) { + const ref = val.referencedObject; + return { + displayValue: val.displayValue, + referencedObject: { + id: typeof ref.id === 'string' ? parseInt(ref.id, 10) : ref.id, + objectKey: ref.objectKey, + label: ref.label, + }, + }; + } + + if (isSimpleValue(val)) { + return { + value: String(val.value), + displayValue: val.displayValue, + }; + } + + // StatusValue, ConfluenceValue, UserValue + return { + displayValue: val.displayValue, + status: 'status' in val ? { name: val.status.name } : undefined, + }; + } + + /** + * Adapt AssetsPayload (from domain types) to legacy JiraAssetsSearchResponse + */ + private adaptAssetsPayloadToSearchResponse( + payload: { objectEntries: ObjectEntry[]; totalCount?: number; totalFilterCount?: number; page?: number; pageSize?: number } + ): JiraAssetsSearchResponse { + return { + objectEntries: payload.objectEntries.map(entry => this.adaptObjectEntryToJiraAssetsObject(entry)!).filter(Boolean), + totalCount: payload.totalCount || 0, + totalFilterCount: payload.totalFilterCount, + page: payload.page || 1, + pageSize: payload.pageSize || 50, + }; + } + // ========================================================================== // Object Parsing // ========================================================================== - parseObject(jiraObj: JiraAssetsObject): T | null { + async parseObject(jiraObj: JiraAssetsObject): Promise { + // Ensure lookup maps are initialized + if (Object.keys(OBJECT_TYPES_CACHE).length === 0) { + await initializeLookupMaps(); + } + 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 for object ${jiraObj.objectKey || jiraObj.id}: ${jiraObj.objectType?.name} (ID: ${typeId})`); + // This is expected when repairing broken references - object types may not be configured + logger.debug(`JiraAssetsClient: Unknown object type for object ${jiraObj.objectKey || jiraObj.id}: ${jiraObj.objectType?.name} (ID: ${typeId}) - object type not configured, skipping`); return null; } - const typeDef = OBJECT_TYPES[typeName]; + const typeDef = OBJECT_TYPES_CACHE[typeName]; if (!typeDef) { logger.warn(`JiraAssetsClient: Type definition not found for type: ${typeName} (object: ${jiraObj.objectKey || jiraObj.id})`); return null; } + // Validate required fields from Jira object + if (jiraObj.id === null || jiraObj.id === undefined) { + logger.warn(`JiraAssetsClient: Object missing id field. ObjectKey: ${jiraObj.objectKey}, Label: ${jiraObj.label}, Type: ${jiraObj.objectType?.name}`); + throw new Error(`Cannot parse Jira object: missing id field`); + } + + if (!jiraObj.objectKey || !String(jiraObj.objectKey).trim()) { + logger.warn(`JiraAssetsClient: Object missing objectKey. ID: ${jiraObj.id}, Label: ${jiraObj.label}, Type: ${jiraObj.objectType?.name}`); + throw new Error(`Cannot parse Jira object ${jiraObj.id}: missing objectKey`); + } + + if (!jiraObj.label || !String(jiraObj.label).trim()) { + logger.warn(`JiraAssetsClient: Object missing label. ID: ${jiraObj.id}, ObjectKey: ${jiraObj.objectKey}, Type: ${jiraObj.objectType?.name}`); + throw new Error(`Cannot parse Jira object ${jiraObj.id}: missing label`); + } + + // Ensure we have valid values before creating the result + const objectId = String(jiraObj.id || ''); + const objectKey = String(jiraObj.objectKey || '').trim(); + const label = String(jiraObj.label || '').trim(); + + // Double-check after conversion (in case String() produced "null" or "undefined") + if (!objectId || objectId === 'null' || objectId === 'undefined' || objectId === 'NaN') { + logger.error(`JiraAssetsClient: parseObject - invalid id after conversion. Original: ${jiraObj.id}, Converted: ${objectId}`); + throw new Error(`Cannot parse Jira object: invalid id after conversion (${objectId})`); + } + + if (!objectKey || objectKey === 'null' || objectKey === 'undefined') { + logger.error(`JiraAssetsClient: parseObject - invalid objectKey after conversion. Original: ${jiraObj.objectKey}, Converted: ${objectKey}`); + throw new Error(`Cannot parse Jira object: invalid objectKey after conversion (${objectKey})`); + } + + if (!label || label === 'null' || label === 'undefined') { + logger.error(`JiraAssetsClient: parseObject - invalid label after conversion. Original: ${jiraObj.label}, Converted: ${label}`); + throw new Error(`Cannot parse Jira object: invalid label after conversion (${label})`); + } + const result: Record = { - id: jiraObj.id.toString(), - objectKey: jiraObj.objectKey, - label: jiraObj.label, + id: objectId, + objectKey: objectKey, + label: label, _objectType: typeName, _jiraUpdatedAt: jiraObj.updated || new Date().toISOString(), _jiraCreatedAt: jiraObj.created || new Date().toISOString(), }; // Parse each attribute based on schema + // IMPORTANT: Don't allow attributes to overwrite id, objectKey, or label + const protectedFields = new Set(['id', 'objectKey', 'label', '_objectType', '_jiraUpdatedAt', '_jiraCreatedAt']); + for (const attrDef of typeDef.attributes) { + // Skip if this attribute would overwrite a protected field + if (protectedFields.has(attrDef.fieldName)) { + logger.warn(`JiraAssetsClient: Skipping attribute ${attrDef.fieldName} (${attrDef.name}) - would overwrite protected field`); + continue; + } + const jiraAttr = this.findAttribute(jiraObj.attributes, attrDef.jiraId, attrDef.name); - const parsedValue = this.parseAttributeValue(jiraAttr, attrDef); + const parsedValue = this.parseAttributeValue(jiraAttr, { + type: attrDef.type, + isMultiple: attrDef.isMultiple ?? false, // Default to false if not specified + fieldName: attrDef.fieldName, + }); result[attrDef.fieldName] = parsedValue; // Debug logging for Confluence Space field @@ -420,6 +829,51 @@ class JiraAssetsClient { } } + // Final validation - ensure result has required fields + // This should never fail if the code above worked correctly, but it's a safety check + const finalId = String(result.id || '').trim(); + const finalObjectKey = String(result.objectKey || '').trim(); + const finalLabel = String(result.label || '').trim(); + + if (!finalId || finalId === 'null' || finalId === 'undefined' || finalId === 'NaN') { + logger.error(`JiraAssetsClient: parseObject result missing or invalid id after all processing. Result: ${JSON.stringify({ + hasId: 'id' in result, + hasObjectKey: 'objectKey' in result, + hasLabel: 'label' in result, + id: result.id, + objectKey: result.objectKey, + label: result.label, + resultKeys: Object.keys(result), + jiraObj: { + id: jiraObj.id, + objectKey: jiraObj.objectKey, + label: jiraObj.label, + objectType: jiraObj.objectType?.name + } + })}`); + throw new Error(`Failed to parse Jira object: result missing or invalid id (${finalId})`); + } + + if (!finalObjectKey || finalObjectKey === 'null' || finalObjectKey === 'undefined') { + logger.error(`JiraAssetsClient: parseObject result missing or invalid objectKey after all processing. Result: ${JSON.stringify({ + id: result.id, + objectKey: result.objectKey, + label: result.label, + resultKeys: Object.keys(result) + })}`); + throw new Error(`Failed to parse Jira object: result missing or invalid objectKey (${finalObjectKey})`); + } + + if (!finalLabel || finalLabel === 'null' || finalLabel === 'undefined') { + logger.error(`JiraAssetsClient: parseObject result missing or invalid label after all processing. Result: ${JSON.stringify({ + id: result.id, + objectKey: result.objectKey, + label: result.label, + resultKeys: Object.keys(result) + })}`); + throw new Error(`Failed to parse Jira object: result missing or invalid label (${finalLabel})`); + } + return result as T; } @@ -449,27 +903,24 @@ class JiraAssetsClient { return attrDef.isMultiple ? [] : null; } - const values = jiraAttr.objectAttributeValues; + // Convert legacy attribute values to domain types for type guard usage + // This allows us to use the type guards while maintaining backward compatibility + const values = jiraAttr.objectAttributeValues as unknown as ObjectAttributeValue[]; + // Use type guards from domain types // Generic Confluence field detection: check if any value has a confluencePage - // This works for all Confluence fields regardless of their declared type (float, text, etc.) - // Type assertion needed because confluencePage is not in the type definition but exists at runtime - type AttributeValueWithConfluence = typeof values[0] & { - confluencePage?: { url?: string }; - }; - const valuesWithConfluence = values as AttributeValueWithConfluence[]; - const hasConfluencePage = valuesWithConfluence.some(v => v.confluencePage); + const hasConfluencePage = values.some(v => 'confluencePage' in v && v.confluencePage); if (hasConfluencePage) { - const confluencePage = valuesWithConfluence[0]?.confluencePage; - if (confluencePage?.url) { - logger.info(`[Confluence Field Parse] Found Confluence URL for field "${attrDef.fieldName || 'unknown'}": ${confluencePage.url}`); + const confluenceVal = values.find(v => 'confluencePage' in v && v.confluencePage) as ConfluenceValue | undefined; + if (confluenceVal?.confluencePage?.url) { + logger.info(`[Confluence Field Parse] Found Confluence URL for field "${attrDef.fieldName || 'unknown'}": ${confluenceVal.confluencePage.url}`); // For multiple values, return array of URLs; for single, return the URL string if (attrDef.isMultiple) { - return valuesWithConfluence - .filter(v => v.confluencePage?.url) - .map(v => v.confluencePage!.url); + return values + .filter((v): v is ConfluenceValue => 'confluencePage' in v && !!v.confluencePage) + .map(v => v.confluencePage.url); } - return confluencePage.url; + return confluenceVal.confluencePage.url; } // Fallback to displayValue if no URL const displayVal = values[0]?.displayValue; @@ -482,12 +933,13 @@ class JiraAssetsClient { switch (attrDef.type) { case 'reference': { + // Use type guard to filter reference values const refs = values - .filter(v => v.referencedObject) + .filter(isReferenceValue) .map(v => ({ - objectId: v.referencedObject!.id.toString(), - objectKey: v.referencedObject!.objectKey, - label: v.referencedObject!.label, + objectId: String(v.referencedObject.id), + objectKey: v.referencedObject.objectKey, + label: v.referencedObject.label, } as ObjectReference)); return attrDef.isMultiple ? refs : refs[0] || null; } @@ -498,7 +950,14 @@ class JiraAssetsClient { case 'email': case 'select': case 'user': { - const val = values[0]?.displayValue ?? values[0]?.value ?? null; + // Use type guard for simple values when available, otherwise fall back to legacy format + const firstVal = values[0]; + let val: string | null = null; + if (isSimpleValue(firstVal)) { + val = String(firstVal.value); + } else { + val = firstVal?.displayValue ?? (firstVal as any)?.value ?? null; + } // Strip HTML if present if (val && typeof val === 'string' && val.includes('<')) { return this.stripHtml(val); @@ -507,14 +966,24 @@ class JiraAssetsClient { } case 'integer': { - const val = values[0]?.value; - return val ? parseInt(val, 10) : null; + const firstVal = values[0]; + if (isSimpleValue(firstVal)) { + const val = typeof firstVal.value === 'number' ? firstVal.value : parseInt(String(firstVal.value), 10); + return isNaN(val) ? null : val; + } + const val = (firstVal as any)?.value; + return val ? parseInt(String(val), 10) : null; } case 'float': { // Regular float parsing - const val = values[0]?.value; - const displayVal = values[0]?.displayValue; + const firstVal = values[0]; + if (isSimpleValue(firstVal)) { + const val = typeof firstVal.value === 'number' ? firstVal.value : parseFloat(String(firstVal.value)); + return isNaN(val) ? null : val; + } + const val = (firstVal as any)?.value; + const displayVal = firstVal?.displayValue; // Try displayValue first, then value if (displayVal !== undefined && displayVal !== null) { const parsed = typeof displayVal === 'string' ? parseFloat(displayVal) : Number(displayVal); @@ -528,25 +997,37 @@ class JiraAssetsClient { } case 'boolean': { - const val = values[0]?.value; + const firstVal = values[0]; + if (isSimpleValue(firstVal)) { + return Boolean(firstVal.value); + } + const val = (firstVal as any)?.value; return val === 'true' || val === 'Ja'; } case 'date': case 'datetime': { - return values[0]?.value ?? values[0]?.displayValue ?? null; + const firstVal = values[0]; + if (isSimpleValue(firstVal)) { + return String(firstVal.value); + } + return firstVal?.displayValue ?? (firstVal as any)?.value ?? null; } case 'status': { - const statusVal = values[0]?.status; - if (statusVal) { - return statusVal.name || null; + const firstVal = values[0]; + if ('status' in firstVal && firstVal.status) { + return firstVal.status.name || null; } - return values[0]?.displayValue ?? values[0]?.value ?? null; + return firstVal?.displayValue ?? (firstVal as any)?.value ?? null; } default: - return values[0]?.displayValue ?? values[0]?.value ?? null; + const firstVal = values[0]; + if (isSimpleValue(firstVal)) { + return String(firstVal.value); + } + return firstVal?.displayValue ?? (firstVal as any)?.value ?? null; } } diff --git a/backend/src/services/mockData.ts b/backend/src/services/mockData.ts deleted file mode 100644 index f8f44b5..0000000 --- a/backend/src/services/mockData.ts +++ /dev/null @@ -1,893 +0,0 @@ -import { calculateRequiredEffortApplicationManagement } from './effortCalculation.js'; -import type { - ApplicationDetails, - ApplicationListItem, - ReferenceValue, - SearchFilters, - SearchResult, - ClassificationResult, - TeamDashboardData, - ApplicationStatus, -} from '../types/index.js'; - -// Mock application data for development/demo -const mockApplications: ApplicationDetails[] = [ - { - id: '1', - key: 'APP-001', - name: 'Epic Hyperspace', - searchReference: 'EPIC-HS', - description: 'Elektronisch Patiëntendossier module voor klinische documentatie en workflow. Ondersteunt de volledige patiëntenzorg van intake tot ontslag.', - supplierProduct: 'Epic Systems / Hyperspace', - organisation: 'Zorg', - hostingType: { objectId: '1', key: 'HOST-1', name: 'On-premises' }, - status: 'In Production', - businessImportance: 'Kritiek', - businessImpactAnalyse: { objectId: '1', key: 'BIA-1', name: 'BIA-2024-0042 (Klasse E)' }, - systemOwner: 'J. Janssen', - businessOwner: 'Dr. A. van der Berg', - functionalApplicationManagement: 'Team EPD', - technicalApplicationManagement: 'Team Zorgapplicaties', - technicalApplicationManagementPrimary: 'Jan Jansen', - technicalApplicationManagementSecondary: 'Piet Pietersen', - medischeTechniek: false, - applicationFunctions: [], - dynamicsFactor: { objectId: '3', key: 'DYN-3', name: '3 - Hoog' }, - complexityFactor: { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog' }, - numberOfUsers: null, - governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' }, - applicationSubteam: null, - applicationTeam: null, - applicationType: null, - platform: null, - requiredEffortApplicationManagement: null, - }, - { - id: '2', - key: 'APP-002', - name: 'SAP Finance', - searchReference: 'SAP-FIN', - description: 'Enterprise Resource Planning systeem voor financiële administratie, budgettering en controlling.', - supplierProduct: 'SAP SE / SAP S/4HANA', - organisation: 'Bedrijfsvoering', - hostingType: { objectId: '3', key: 'HOST-3', name: 'Cloud' }, - status: 'In Production', - businessImportance: 'Kritiek', - businessImpactAnalyse: { objectId: '2', key: 'BIA-2', name: 'BIA-2024-0015 (Klasse D)' }, - systemOwner: 'M. de Groot', - businessOwner: 'P. Bakker', - functionalApplicationManagement: 'Team ERP', - technicalApplicationManagement: 'Team Bedrijfsapplicaties', - medischeTechniek: false, - applicationFunctions: [], - dynamicsFactor: { objectId: '2', key: 'DYN-2', name: '2 - Gemiddeld' }, - complexityFactor: { objectId: '3', key: 'CMP-3', name: '3 - Hoog' }, - numberOfUsers: null, - governanceModel: null, - applicationSubteam: null, - applicationTeam: null, - applicationType: null, - platform: null, - requiredEffortApplicationManagement: null, - }, - { - id: '3', - key: 'APP-003', - name: 'Philips IntelliSpace PACS', - searchReference: 'PACS', - description: 'Picture Archiving and Communication System voor opslag en weergave van medische beelden inclusief radiologie, CT en MRI.', - supplierProduct: 'Philips Healthcare / IntelliSpace PACS', - organisation: 'Zorg', - hostingType: { objectId: '1', key: 'HOST-1', name: 'On-premises' }, - status: 'In Production', - businessImportance: 'Hoog', - businessImpactAnalyse: { objectId: '3', key: 'BIA-3', name: 'BIA-2024-0028 (Klasse D)' }, - systemOwner: 'R. Hermans', - businessOwner: 'Dr. K. Smit', - functionalApplicationManagement: 'Team Beeldvorming', - technicalApplicationManagement: 'Team Zorgapplicaties', - medischeTechniek: true, - applicationFunctions: [], - dynamicsFactor: null, - complexityFactor: null, - numberOfUsers: null, - governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' }, - applicationSubteam: null, - applicationTeam: null, - applicationType: null, - platform: null, - requiredEffortApplicationManagement: null, - }, - { - id: '4', - key: 'APP-004', - name: 'ChipSoft HiX', - searchReference: 'HIX', - description: 'Ziekenhuisinformatiesysteem en EPD voor patiëntregistratie, zorgplanning en klinische workflow.', - supplierProduct: 'ChipSoft / HiX', - organisation: 'Zorg', - hostingType: { objectId: '1', key: 'HOST-1', name: 'On-premises' }, - status: 'In Production', - businessImportance: 'Kritiek', - businessImpactAnalyse: { objectId: '5', key: 'BIA-5', name: 'BIA-2024-0001 (Klasse F)' }, - systemOwner: 'T. van Dijk', - businessOwner: 'Dr. L. Mulder', - functionalApplicationManagement: 'Team ZIS', - technicalApplicationManagement: 'Team Zorgapplicaties', - medischeTechniek: false, - applicationFunctions: [], - dynamicsFactor: { objectId: '4', key: 'DYN-4', name: '4 - Zeer hoog' }, - complexityFactor: { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog' }, - numberOfUsers: null, - governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' }, - applicationSubteam: null, - applicationTeam: null, - applicationType: null, - platform: null, - requiredEffortApplicationManagement: null, - }, - { - id: '5', - key: 'APP-005', - name: 'TOPdesk', - searchReference: 'TOPDESK', - description: 'IT Service Management platform voor incident, problem en change management.', - supplierProduct: 'TOPdesk / TOPdesk Enterprise', - organisation: 'ICMT', - hostingType: { objectId: '2', key: 'HOST-2', name: 'SaaS' }, - status: 'In Production', - businessImportance: 'Hoog', - businessImpactAnalyse: { objectId: '6', key: 'BIA-6', name: 'BIA-2024-0055 (Klasse C)' }, - systemOwner: 'B. Willems', - businessOwner: 'H. Claessen', - functionalApplicationManagement: 'Team Servicedesk', - technicalApplicationManagement: 'Team ICT Beheer', - medischeTechniek: false, - applicationFunctions: [], - dynamicsFactor: { objectId: '2', key: 'DYN-2', name: '2 - Gemiddeld' }, - complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' }, - numberOfUsers: null, - governanceModel: null, - applicationSubteam: null, - applicationTeam: null, - applicationType: null, - platform: null, - requiredEffortApplicationManagement: null, - }, - { - id: '6', - key: 'APP-006', - name: 'Microsoft 365', - searchReference: 'M365', - description: 'Kantoorautomatisering suite met Teams, Outlook, SharePoint, OneDrive en Office applicaties.', - supplierProduct: 'Microsoft / Microsoft 365 E5', - organisation: 'ICMT', - hostingType: { objectId: '2', key: 'HOST-2', name: 'SaaS' }, - status: 'In Production', - businessImportance: 'Kritiek', - businessImpactAnalyse: { objectId: '1', key: 'BIA-1', name: 'BIA-2024-0042 (Klasse E)' }, - systemOwner: 'S. Jansen', - businessOwner: 'N. Peters', - functionalApplicationManagement: 'Team Werkplek', - technicalApplicationManagement: 'Team Cloud', - medischeTechniek: false, - applicationFunctions: [], - dynamicsFactor: { objectId: '3', key: 'DYN-3', name: '3 - Hoog' }, - 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' }, - applicationSubteam: null, - applicationTeam: null, - applicationType: null, - platform: null, - requiredEffortApplicationManagement: null, - }, - { - id: '7', - key: 'APP-007', - name: 'Carestream Vue PACS', - searchReference: 'VUE-PACS', - description: 'Enterprise imaging platform voor radiologie en cardiologie beeldvorming.', - supplierProduct: 'Carestream Health / Vue PACS', - organisation: 'Zorg', - hostingType: { objectId: '1', key: 'HOST-1', name: 'On-premises' }, - status: 'End of life', - businessImportance: 'Gemiddeld', - businessImpactAnalyse: { objectId: '9', key: 'BIA-9', name: 'BIA-2022-0089 (Klasse C)' }, - systemOwner: 'R. Hermans', - businessOwner: 'Dr. K. Smit', - functionalApplicationManagement: 'Team Beeldvorming', - technicalApplicationManagement: 'Team Zorgapplicaties', - medischeTechniek: true, - applicationFunctions: [], - dynamicsFactor: { objectId: '1', key: 'DYN-1', name: '1 - Stabiel' }, - complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' }, - numberOfUsers: null, - governanceModel: null, - applicationSubteam: null, - applicationTeam: null, - applicationType: null, - platform: null, - requiredEffortApplicationManagement: null, - }, - { - id: '8', - key: 'APP-008', - name: 'AFAS Profit', - searchReference: 'AFAS', - description: 'HR en salarisadministratie systeem voor personeelsbeheer, tijdregistratie en verloning.', - supplierProduct: 'AFAS Software / Profit', - organisation: 'Bedrijfsvoering', - hostingType: { objectId: '2', key: 'HOST-2', name: 'SaaS' }, - status: 'In Production', - businessImportance: 'Hoog', - businessImpactAnalyse: { objectId: '7', key: 'BIA-7', name: 'BIA-2024-0022 (Klasse D)' }, - systemOwner: 'E. Hendriks', - businessOwner: 'C. van Leeuwen', - functionalApplicationManagement: 'Team HR', - technicalApplicationManagement: 'Team Bedrijfsapplicaties', - medischeTechniek: false, - applicationFunctions: [], - dynamicsFactor: { objectId: '2', key: 'DYN-2', name: '2 - Gemiddeld' }, - 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' }, - applicationSubteam: null, - applicationTeam: null, - applicationType: null, - platform: null, - requiredEffortApplicationManagement: null, - }, - { - id: '9', - key: 'APP-009', - name: 'Zenya', - searchReference: 'ZENYA', - description: 'Kwaliteitsmanagementsysteem voor protocollen, procedures en incidentmeldingen.', - supplierProduct: 'Infoland / Zenya', - organisation: 'Kwaliteit', - hostingType: { objectId: '2', key: 'HOST-2', name: 'SaaS' }, - status: 'In Production', - businessImportance: 'Hoog', - businessImpactAnalyse: { objectId: '8', key: 'BIA-8', name: 'BIA-2024-0067 (Klasse C)' }, - systemOwner: 'F. Bos', - businessOwner: 'I. Dekker', - functionalApplicationManagement: 'Team Kwaliteit', - technicalApplicationManagement: 'Team Bedrijfsapplicaties', - medischeTechniek: false, - applicationFunctions: [], - dynamicsFactor: { objectId: '2', key: 'DYN-2', name: '2 - Gemiddeld' }, - 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' }, - applicationSubteam: null, - applicationTeam: null, - applicationType: null, - platform: null, - requiredEffortApplicationManagement: null, - }, - { - id: '10', - key: 'APP-010', - name: 'Castor EDC', - searchReference: 'CASTOR', - description: 'Electronic Data Capture platform voor klinisch wetenschappelijk onderzoek en trials.', - supplierProduct: 'Castor / Castor EDC', - organisation: 'Onderzoek', - hostingType: { objectId: '2', key: 'HOST-2', name: 'SaaS' }, - status: 'In Production', - businessImportance: 'Gemiddeld', - businessImpactAnalyse: null, // BIA-2024-0078 (Klasse B) not in mock list - systemOwner: 'G. Vos', - businessOwner: 'Prof. Dr. W. Maas', - functionalApplicationManagement: 'Team Onderzoek', - technicalApplicationManagement: null, - medischeTechniek: false, - applicationFunctions: [], - dynamicsFactor: { objectId: '1', key: 'DYN-1', name: '1 - Stabiel' }, - 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' }, - applicationSubteam: null, - applicationTeam: null, - applicationType: null, - platform: null, - requiredEffortApplicationManagement: null, - }, -]; - -// Mock reference data -const mockDynamicsFactors: ReferenceValue[] = [ - { objectId: '1', key: 'DYN-1', name: '1 - Stabiel', summary: 'Weinig wijzigingen, < 2 releases/jaar', description: 'Weinig wijzigingen, < 2 releases/jaar', factor: 0.8 }, - { objectId: '2', key: 'DYN-2', name: '2 - Gemiddeld', summary: 'Regelmatige wijzigingen, 2-4 releases/jaar', description: 'Regelmatige wijzigingen, 2-4 releases/jaar', factor: 1.0 }, - { objectId: '3', key: 'DYN-3', name: '3 - Hoog', summary: 'Veel wijzigingen, > 4 releases/jaar', description: 'Veel wijzigingen, > 4 releases/jaar', factor: 1.2 }, - { objectId: '4', key: 'DYN-4', name: '4 - Zeer hoog', summary: 'Continu in beweging, grote transformaties', description: 'Continu in beweging, grote transformaties', factor: 1.5 }, -]; - -const mockComplexityFactors: ReferenceValue[] = [ - { objectId: '1', key: 'CMP-1', name: '1 - Laag', summary: 'Standalone, weinig integraties', description: 'Standalone, weinig integraties', factor: 0.8 }, - { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld', summary: 'Enkele integraties, beperkt maatwerk', description: 'Enkele integraties, beperkt maatwerk', factor: 1.0 }, - { objectId: '3', key: 'CMP-3', name: '3 - Hoog', summary: 'Veel integraties, significant maatwerk', description: 'Veel integraties, significant maatwerk', factor: 1.3 }, - { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog', summary: 'Platform, uitgebreide governance', description: 'Platform, uitgebreide governance', factor: 1.6 }, -]; - -const mockNumberOfUsers: ReferenceValue[] = [ - { objectId: '1', key: 'USR-1', name: '< 100', order: 1, factor: 0.5 }, - { objectId: '2', key: 'USR-2', name: '100 - 500', order: 2, factor: 0.7 }, - { objectId: '3', key: 'USR-3', name: '500 - 2.000', order: 3, factor: 1.0 }, - { objectId: '4', key: 'USR-4', name: '2.000 - 5.000', order: 4, factor: 1.2 }, - { objectId: '5', key: 'USR-5', name: '5.000 - 10.000', order: 5, factor: 1.4 }, - { objectId: '6', key: 'USR-6', name: '10.000 - 15.000', order: 6, factor: 1.6 }, - { objectId: '7', key: 'USR-7', name: '> 15.000', order: 7, factor: 2.0 }, -]; - -const mockGovernanceModels: ReferenceValue[] = [ - { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer', summary: 'ICMT voert volledig beheer uit', description: 'ICMT voert volledig beheer uit' }, - { objectId: 'B', key: 'GOV-B', name: 'Federatief Beheer', summary: 'ICMT + business delen beheer', description: 'ICMT + business delen beheer' }, - { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie', summary: 'Leverancier beheert, ICMT regisseert', description: 'Leverancier beheert, ICMT regisseert' }, - { objectId: 'D', key: 'GOV-D', name: 'Uitbesteed met Business-Regie', summary: 'Leverancier beheert, business regisseert', description: 'Leverancier beheert, business regisseert' }, - { objectId: 'E', key: 'GOV-E', name: 'Volledig Decentraal Beheer', summary: 'Business voert volledig beheer uit', description: 'Business voert volledig beheer uit' }, -]; - -const mockOrganisations: ReferenceValue[] = [ - { objectId: '1', key: 'ORG-1', name: 'Zorg' }, - { objectId: '2', key: 'ORG-2', name: 'Bedrijfsvoering' }, - { objectId: '3', key: 'ORG-3', name: 'ICMT' }, - { objectId: '4', key: 'ORG-4', name: 'Kwaliteit' }, - { objectId: '5', key: 'ORG-5', name: 'Onderzoek' }, - { objectId: '6', key: 'ORG-6', name: 'Onderwijs' }, -]; - -const mockHostingTypes: ReferenceValue[] = [ - { objectId: '1', key: 'HOST-1', name: 'On-premises' }, - { objectId: '2', key: 'HOST-2', name: 'SaaS' }, - { objectId: '3', key: 'HOST-3', name: 'Cloud' }, - { objectId: '4', key: 'HOST-4', name: 'Hybrid' }, -]; - -const mockBusinessImpactAnalyses: ReferenceValue[] = [ - { objectId: '1', key: 'BIA-1', name: 'BIA-2024-0042 (Klasse E)' }, - { objectId: '2', key: 'BIA-2', name: 'BIA-2024-0015 (Klasse D)' }, - { objectId: '3', key: 'BIA-3', name: 'BIA-2024-0028 (Klasse D)' }, - { objectId: '4', key: 'BIA-4', name: 'BIA-2024-0035 (Klasse C)' }, - { objectId: '5', key: 'BIA-5', name: 'BIA-2024-0001 (Klasse F)' }, - { objectId: '6', key: 'BIA-6', name: 'BIA-2024-0055 (Klasse C)' }, - { objectId: '7', key: 'BIA-7', name: 'BIA-2024-0022 (Klasse D)' }, - { objectId: '8', key: 'BIA-8', name: 'BIA-2024-0067 (Klasse C)' }, - { objectId: '9', key: 'BIA-9', name: 'BIA-2022-0089 (Klasse C)' }, -]; - -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[] = [ - { objectId: '1', key: 'TYPE-1', name: 'Applicatie' }, - { objectId: '2', key: 'TYPE-2', name: 'Platform' }, - { objectId: '3', key: 'TYPE-3', name: 'Workload' }, -]; - -// Classification history -const mockClassificationHistory: ClassificationResult[] = []; - -// Mock data service -export class MockDataService { - private applications: ApplicationDetails[] = [...mockApplications]; - - async searchApplications( - filters: SearchFilters, - page: number = 1, - pageSize: number = 25 - ): Promise { - let filtered = [...this.applications]; - - // Apply search text filter - if (filters.searchText) { - const search = filters.searchText.toLowerCase(); - filtered = filtered.filter( - (app) => - app.name.toLowerCase().includes(search) || - (app.description?.toLowerCase().includes(search) ?? false) || - (app.supplierProduct?.toLowerCase().includes(search) ?? false) || - (app.searchReference?.toLowerCase().includes(search) ?? false) - ); - } - - // Apply status filter - if (filters.statuses && filters.statuses.length > 0) { - filtered = filtered.filter((app) => { - // Handle empty/null status - treat as 'Undefined' for filtering - const status = app.status || 'Undefined'; - return filters.statuses!.includes(status as ApplicationStatus); - }); - } - - // Apply applicationFunction filter - if (filters.applicationFunction === 'empty') { - filtered = filtered.filter((app) => app.applicationFunctions.length === 0); - } else if (filters.applicationFunction === 'filled') { - filtered = filtered.filter((app) => app.applicationFunctions.length > 0); - } - - // Apply governanceModel filter - if (filters.governanceModel === 'empty') { - filtered = filtered.filter((app) => !app.governanceModel); - } else if (filters.governanceModel === 'filled') { - filtered = filtered.filter((app) => !!app.governanceModel); - } - - // Apply dynamicsFactor filter - if (filters.dynamicsFactor === 'empty') { - filtered = filtered.filter((app) => !app.dynamicsFactor); - } else if (filters.dynamicsFactor === 'filled') { - filtered = filtered.filter((app) => !!app.dynamicsFactor); - } - - // Apply complexityFactor filter - if (filters.complexityFactor === 'empty') { - filtered = filtered.filter((app) => !app.complexityFactor); - } else if (filters.complexityFactor === 'filled') { - filtered = filtered.filter((app) => !!app.complexityFactor); - } - - // 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 - if (filters.applicationType === 'empty') { - filtered = filtered.filter((app) => !app.applicationType); - } else if (filters.applicationType === 'filled') { - filtered = filtered.filter((app) => !!app.applicationType); - } - - // Apply organisation filter - if (filters.organisation) { - filtered = filtered.filter((app) => app.organisation === filters.organisation); - } - - // Apply hostingType filter - if (filters.hostingType) { - filtered = filtered.filter((app) => { - if (!app.hostingType) return false; - return app.hostingType.name === filters.hostingType || app.hostingType.key === filters.hostingType; - }); - } - - if (filters.businessImportance) { - filtered = filtered.filter((app) => app.businessImportance === filters.businessImportance); - } - - const totalCount = filtered.length; - const totalPages = Math.ceil(totalCount / pageSize); - const startIndex = (page - 1) * pageSize; - const paginatedApps = filtered.slice(startIndex, startIndex + pageSize); - - return { - applications: paginatedApps.map((app) => { - const effort = calculateRequiredEffortApplicationManagement(app); - return { - id: app.id, - key: app.key, - name: app.name, - status: app.status, - applicationFunctions: app.applicationFunctions, - governanceModel: app.governanceModel, - dynamicsFactor: app.dynamicsFactor, - complexityFactor: app.complexityFactor, - applicationSubteam: app.applicationSubteam, - applicationTeam: app.applicationTeam, - applicationType: app.applicationType, - platform: app.platform, - requiredEffortApplicationManagement: effort, - }; - }), - totalCount, - currentPage: page, - pageSize, - totalPages, - }; - } - - async getApplicationById(id: string): Promise { - const app = this.applications.find((app) => app.id === id); - if (!app) return null; - - // Calculate required effort - const effort = calculateRequiredEffortApplicationManagement(app); - return { - ...app, - requiredEffortApplicationManagement: effort, - }; - } - - async updateApplication( - id: string, - updates: { - applicationFunctions?: ReferenceValue[]; - dynamicsFactor?: ReferenceValue; - complexityFactor?: ReferenceValue; - numberOfUsers?: ReferenceValue; - governanceModel?: ReferenceValue; - applicationSubteam?: ReferenceValue; - applicationTeam?: ReferenceValue; - applicationType?: ReferenceValue; - hostingType?: ReferenceValue; - businessImpactAnalyse?: ReferenceValue; - } - ): Promise { - const index = this.applications.findIndex((app) => app.id === id); - if (index === -1) return false; - - const app = this.applications[index]; - - if (updates.applicationFunctions !== undefined) { - app.applicationFunctions = updates.applicationFunctions; - } - if (updates.dynamicsFactor !== undefined) { - app.dynamicsFactor = updates.dynamicsFactor; - } - if (updates.complexityFactor !== undefined) { - app.complexityFactor = updates.complexityFactor; - } - if (updates.numberOfUsers !== undefined) { - app.numberOfUsers = updates.numberOfUsers; - } - if (updates.governanceModel !== undefined) { - app.governanceModel = updates.governanceModel; - } - if (updates.applicationSubteam !== undefined) { - app.applicationSubteam = updates.applicationSubteam; - } - if (updates.applicationTeam !== undefined) { - app.applicationTeam = updates.applicationTeam; - } - if (updates.applicationType !== undefined) { - app.applicationType = updates.applicationType; - } - if (updates.hostingType !== undefined) { - app.hostingType = updates.hostingType; - } - if (updates.businessImpactAnalyse !== undefined) { - app.businessImpactAnalyse = updates.businessImpactAnalyse; - } - - return true; - } - - async getDynamicsFactors(): Promise { - return mockDynamicsFactors; - } - - async getComplexityFactors(): Promise { - return mockComplexityFactors; - } - - async getNumberOfUsers(): Promise { - return mockNumberOfUsers; - } - - async getGovernanceModels(): Promise { - return mockGovernanceModels; - } - - async getOrganisations(): Promise { - return mockOrganisations; - } - - async getHostingTypes(): Promise { - return mockHostingTypes; - } - - async getBusinessImpactAnalyses(): Promise { - return mockBusinessImpactAnalyses; - } - - async getApplicationManagementHosting(): Promise { - // Mock Application Management - Hosting values (v25) - return [ - { objectId: '1', key: 'AMH-1', name: 'On-Premises' }, - { objectId: '2', key: 'AMH-2', name: 'Azure - Eigen beheer' }, - { objectId: '3', key: 'AMH-3', name: 'Azure - Delegated Management' }, - { objectId: '4', key: 'AMH-4', name: 'Extern (SaaS)' }, - ]; - } - - async getApplicationManagementTAM(): Promise { - // Mock Application Management - TAM values - return [ - { objectId: '1', key: 'TAM-1', name: 'ICMT' }, - { objectId: '2', key: 'TAM-2', name: 'Business' }, - { objectId: '3', key: 'TAM-3', name: 'Leverancier' }, - ]; - } - - async getApplicationFunctions(): Promise { - // Return empty for mock - in real implementation, this comes from Jira - return []; - } - - async getApplicationSubteams(): Promise { - // Return empty for mock - in real implementation, this comes from Jira - return []; - } - - async getApplicationTypes(): Promise { - // Return empty for mock - in real implementation, this comes from Jira - return []; - } - - async getBusinessImportance(): Promise { - // Return empty for mock - in real implementation, this comes from Jira - return []; - } - - async getApplicationFunctionCategories(): Promise { - // Return empty for mock - in real implementation, this comes from Jira - return []; - } - - async getStats() { - // Filter out applications with status "Closed" for KPIs - const activeApplications = this.applications.filter((a) => a.status !== 'Closed'); - const total = activeApplications.length; - const classified = activeApplications.filter((a) => a.applicationFunctions.length > 0).length; - const unclassified = total - classified; - - const byStatus: Record = {}; - const byGovernanceModel: Record = {}; - - activeApplications.forEach((app) => { - if (app.status) { - byStatus[app.status] = (byStatus[app.status] || 0) + 1; - } - if (app.governanceModel) { - byGovernanceModel[app.governanceModel.name] = - (byGovernanceModel[app.governanceModel.name] || 0) + 1; - } - }); - - return { - totalApplications: total, - classifiedCount: classified, - unclassifiedCount: unclassified, - byStatus, - byDomain: {}, - byGovernanceModel, - recentClassifications: mockClassificationHistory.slice(-10), - }; - } - - addClassificationResult(result: ClassificationResult): void { - mockClassificationHistory.push(result); - } - - getClassificationHistory(): ClassificationResult[] { - return [...mockClassificationHistory]; - } - - async getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise { - // Convert ApplicationDetails to ApplicationListItem for dashboard - let listItems: ApplicationListItem[] = this.applications.map(app => ({ - id: app.id, - key: app.key, - name: app.name, - status: app.status, - applicationFunctions: app.applicationFunctions, - governanceModel: app.governanceModel, - dynamicsFactor: app.dynamicsFactor, - complexityFactor: app.complexityFactor, - applicationSubteam: app.applicationSubteam, - applicationTeam: app.applicationTeam, - applicationType: app.applicationType, - platform: app.platform, - requiredEffortApplicationManagement: app.requiredEffortApplicationManagement, - })); - - // Filter out excluded statuses - if (excludedStatuses.length > 0) { - listItems = listItems.filter(app => !app.status || !excludedStatuses.includes(app.status)); - } - - // Separate applications into Platforms, Workloads, and regular applications - const platforms: ApplicationListItem[] = []; - const workloads: ApplicationListItem[] = []; - const regularApplications: ApplicationListItem[] = []; - - for (const app of listItems) { - const isPlatform = app.applicationType?.name === 'Platform'; - const isWorkload = app.platform !== null; - - if (isPlatform) { - platforms.push(app); - } else if (isWorkload) { - workloads.push(app); - } else { - regularApplications.push(app); - } - } - - // Group workloads by their platform - const workloadsByPlatform = new Map(); - for (const workload of workloads) { - const platformId = workload.platform!.objectId; - if (!workloadsByPlatform.has(platformId)) { - workloadsByPlatform.set(platformId, []); - } - workloadsByPlatform.get(platformId)!.push(workload); - } - - // Build PlatformWithWorkloads structures - const platformsWithWorkloads: import('../types/index.js').PlatformWithWorkloads[] = []; - for (const platform of platforms) { - const platformWorkloads = workloadsByPlatform.get(platform.id) || []; - const platformEffort = platform.requiredEffortApplicationManagement || 0; - const workloadsEffort = platformWorkloads.reduce((sum, w) => sum + (w.requiredEffortApplicationManagement || 0), 0); - - platformsWithWorkloads.push({ - platform, - workloads: platformWorkloads, - platformEffort, - workloadsEffort, - totalEffort: platformEffort + workloadsEffort, - }); - } - - // Group all applications (regular + platforms + workloads) by subteam - const subteamMap = new Map(); - const unassigned: { - regular: ApplicationListItem[]; - platforms: import('../types/index.js').PlatformWithWorkloads[]; - } = { - regular: [], - platforms: [], - }; - - // Group regular applications by subteam - for (const app of regularApplications) { - if (app.applicationSubteam) { - const subteamId = app.applicationSubteam.objectId; - if (!subteamMap.has(subteamId)) { - subteamMap.set(subteamId, { regular: [], platforms: [] }); - } - subteamMap.get(subteamId)!.regular.push(app); - } else { - unassigned.regular.push(app); - } - } - - // Group platforms by subteam - for (const platformWithWorkloads of platformsWithWorkloads) { - const platform = platformWithWorkloads.platform; - if (platform.applicationSubteam) { - const subteamId = platform.applicationSubteam.objectId; - if (!subteamMap.has(subteamId)) { - subteamMap.set(subteamId, { regular: [], platforms: [] }); - } - subteamMap.get(subteamId)!.platforms.push(platformWithWorkloads); - } else { - unassigned.platforms.push(platformWithWorkloads); - } - } - - // 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) => - sum + (app.requiredEffortApplicationManagement || 0), 0 - ); - const platformsEffort = platforms.reduce((sum, p) => sum + p.totalEffort, 0); - const totalEffort = regularEffort + platformsEffort; - - // Calculate total application count: regular apps + platforms + workloads - const platformsCount = platforms.length; - const workloadsCount = platforms.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'; - byGovernanceModel[govModel] = (byGovernanceModel[govModel] || 0) + 1; - // Also count workloads - for (const workload of platformWithWorkloads.workloads) { - const workloadGovModel = workload.governanceModel?.name || 'Niet ingesteld'; - byGovernanceModel[workloadGovModel] = (byGovernanceModel[workloadGovModel] || 0) + 1; - } - } - - return { - subteam: subteamRef, - applications: regularApps, - platforms, - totalEffort, - minEffort: totalEffort * 0.8, // Mock: min is 80% of total - maxEffort: totalEffort * 1.2, // Mock: max is 120% of total - 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) => - sum + (app.requiredEffortApplicationManagement || 0), 0 - ); - const unassignedPlatformsEffort = unassigned.platforms.reduce((sum, p) => sum + p.totalEffort, 0); - const unassignedTotalEffort = unassignedRegularEffort + unassignedPlatformsEffort; - - 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; - } - } - - return { - teams: subteams.length > 0 ? [virtualTeam] : [], - unassigned: { - subteam: null, - applications: unassigned.regular, - platforms: unassigned.platforms, - totalEffort: unassignedTotalEffort, - minEffort: unassignedTotalEffort * 0.8, // Mock: min is 80% of total - maxEffort: unassignedTotalEffort * 1.2, // Mock: max is 120% of total - applicationCount: unassignedApplicationCount, - byGovernanceModel: unassignedByGovernanceModel, - }, - }; - } -} - -export const mockDataService = new MockDataService(); diff --git a/backend/src/services/normalizedCacheStore.ts b/backend/src/services/normalizedCacheStore.ts new file mode 100644 index 0000000..c2f67c1 --- /dev/null +++ b/backend/src/services/normalizedCacheStore.ts @@ -0,0 +1,1736 @@ +/** + * NormalizedCacheStore - Normalized database storage for CMDB objects + * + * Provides fast, indexed storage using fully normalized tables. + * Replaces JSONB-based storage with efficient SQL queries. + */ + +import { join, dirname } from 'path'; +import { logger } from './logger.js'; +import type { CMDBObject, CMDBObjectTypeName, ObjectReference } from '../generated/jira-types.js'; +import { getDatabaseAdapter } from './database/singleton.js'; +import type { DatabaseAdapter } from './database/interface.js'; +import { schemaDiscoveryService } from './schemaDiscoveryService.js'; +import { queryBuilder } from './queryBuilder.js'; +import { NORMALIZED_SCHEMA_POSTGRES, NORMALIZED_SCHEMA_SQLITE } from './database/normalized-schema.js'; +import type { AttributeDefinition } from '../generated/jira-schema.js'; + +// Re-export interfaces for compatibility +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 NormalizedCacheStore { + private db: DatabaseAdapter; + private initialized: boolean = false; + private initializationPromise: Promise | null = null; + private isPostgres: boolean = false; + // Cache for referenced objects to avoid N+1 queries + private referenceCache: Map = new Map(); + + constructor() { + // Use shared database adapter singleton + this.db = getDatabaseAdapter(); + // Determine if PostgreSQL based on adapter type + this.isPostgres = (this.db.isPostgres === true); + this.initializationPromise = this.initialize(); + } + + /** + * Ensure database is initialized before executing queries + */ + private async ensureInitialized(): Promise { + if (this.initialized) return; + if (this.initializationPromise) { + await this.initializationPromise; + return; + } + this.initializationPromise = this.initialize(); + await this.initializationPromise; + } + + private async initialize(): Promise { + if (this.initialized) return; + + try { + // Create normalized schema + const schema = this.isPostgres ? NORMALIZED_SCHEMA_POSTGRES : NORMALIZED_SCHEMA_SQLITE; + await this.db.exec(schema); + logger.info(`NormalizedCacheStore: Database schema initialized (${this.isPostgres ? 'PostgreSQL' : 'SQLite'})`); + + // Run migrations to add any missing columns (e.g., search_enabled) + await this.migrateSchema(); + + // Always run backfill to ensure reference metadata is populated + await this.backfillReferenceMetadata(); + + // Schema discovery is now manual - user must discover schemas and configure object types + // via the Schema Configuration settings page before sync can start + // Schema discovery will be triggered automatically when needed (e.g., for relation extraction) + // but only after the user has configured which object types to sync + + this.initialized = true; + logger.info('NormalizedCacheStore: Database initialization complete (schema discovery disabled - use Schema Configuration page)'); + } catch (error) { + logger.error('NormalizedCacheStore: Failed to initialize database schema', error); + throw error; + } + } + + /** + * Migrate schema to add missing columns + */ + private async migrateSchema(): Promise { + try { + // Add search_enabled column to schemas table if it doesn't exist + try { + if (this.isPostgres) { + await this.db.execute(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'schemas' AND column_name = 'search_enabled' + ) THEN + ALTER TABLE schemas ADD COLUMN search_enabled BOOLEAN NOT NULL DEFAULT TRUE; + CREATE INDEX IF NOT EXISTS idx_schemas_search_enabled ON schemas(search_enabled); + END IF; + END $$; + `); + } else { + // SQLite - check if column exists by trying to select it + try { + await this.db.queryOne('SELECT search_enabled FROM schemas LIMIT 1'); + } catch { + // Column doesn't exist, add it + await this.db.execute('ALTER TABLE schemas ADD COLUMN search_enabled INTEGER NOT NULL DEFAULT 1'); + await this.db.execute('CREATE INDEX IF NOT EXISTS idx_schemas_search_enabled ON schemas(search_enabled)'); + } + } + logger.info('NormalizedCacheStore: Schema migration completed - search_enabled column added if needed'); + } catch (error) { + // Column might already exist, which is fine + logger.debug('NormalizedCacheStore: search_enabled column migration (may already exist)', error); + } + } catch (error) { + // Migration errors are not fatal - log and continue + logger.warn('NormalizedCacheStore: Schema migration encountered an error (this is OK if columns already exist)', error); + } + } + + /** + * Backfill reference_object_key and reference_object_label for existing data + * by looking up referenced objects in the objects table + */ + private async backfillReferenceMetadata(): Promise { + try { + let updated = 0; + if (this.isPostgres) { + const result = await this.db.execute(` + UPDATE attribute_values av + SET + reference_object_key = COALESCE(av.reference_object_key, o.object_key), + reference_object_label = COALESCE(av.reference_object_label, o.label) + FROM objects o + WHERE av.reference_object_id = o.id + AND av.reference_object_id IS NOT NULL + AND (av.reference_object_key IS NULL OR av.reference_object_label IS NULL) + `); + updated = result; + } else { + const result = await this.db.execute(` + UPDATE attribute_values + SET + reference_object_key = COALESCE(reference_object_key, + (SELECT object_key FROM objects WHERE id = attribute_values.reference_object_id)), + reference_object_label = COALESCE(reference_object_label, + (SELECT label FROM objects WHERE id = attribute_values.reference_object_id)) + WHERE reference_object_id IS NOT NULL + AND (reference_object_key IS NULL OR reference_object_label IS NULL) + `); + updated = result; + } + + if (updated > 0) { + logger.info(`NormalizedCacheStore: Backfill completed - updated ${updated} reference metadata entries`); + } else { + logger.debug('NormalizedCacheStore: Backfill completed - no updates needed'); + } + + // Count how many references still have missing metadata (referenced objects not in cache) + const missingCount = await this.db.queryOne<{ count: number }>(` + SELECT COUNT(*) as count + FROM attribute_values + WHERE reference_object_id IS NOT NULL + AND (reference_object_key IS NULL OR reference_object_label IS NULL) + `); + + if (missingCount && missingCount.count > 0) { + logger.warn(`NormalizedCacheStore: ${missingCount.count} references still have missing metadata. These referenced objects are not in the cache. Consider running a full sync to populate all referenced objects.`); + } + } catch (error) { + logger.warn('NormalizedCacheStore: Backfill encountered an error (non-fatal)', error); + } + } + + // ========================================================================== + // Object CRUD Operations + // ========================================================================== + + /** + * Get a single object by ID + */ + async getObject(typeName: CMDBObjectTypeName, id: string): Promise { + try { + await this.ensureInitialized(); + return await this.reconstructObject(id, typeName); + } catch (error) { + logger.error(`NormalizedCacheStore: Failed to get object ${id}`, error); + return null; + } + } + + /** + * Get a single object by object key (e.g., "ICMT-123") + */ + async getObjectByKey(typeName: CMDBObjectTypeName, objectKey: string): Promise { + await this.ensureInitialized(); + + const row = await this.db.queryOne<{ id: string }>(` + SELECT id FROM objects + WHERE object_key = ? AND object_type_name = ? + `, [objectKey, typeName]); + + if (!row) return null; + + return await this.reconstructObject(row.id, typeName); + } + + /** + * Get all objects of a specific type + */ + async getObjects( + typeName: CMDBObjectTypeName, + options?: QueryOptions + ): Promise { + try { + await this.ensureInitialized(); + const limit = options?.limit || 10000; + const offset = options?.offset || 0; + const orderBy = queryBuilder.buildOrderBy(options?.orderBy, options?.orderDir); + const pagination = queryBuilder.buildPagination(limit, offset); + + const rows = await this.db.query<{ id: string }>(` + SELECT id FROM objects o + WHERE o.object_type_name = ? + ${orderBy} + ${pagination} + `, [typeName]); + + logger.debug(`NormalizedCacheStore: Found ${rows.length} objects of type ${typeName} in database`); + + const objects = await Promise.all( + rows.map(row => this.reconstructObject(row.id, typeName)) + ); + + const validObjects = objects.filter((obj): obj is T => obj !== null); + logger.debug(`NormalizedCacheStore: Successfully reconstructed ${validObjects.length}/${rows.length} objects of type ${typeName}`); + + return validObjects; + } catch (error) { + logger.error(`NormalizedCacheStore: Failed to get objects for type ${typeName}`, error); + return []; + } + } + + /** + * Count objects of a specific type + */ + async countObjects(typeName: CMDBObjectTypeName): Promise { + await this.ensureInitialized(); + const row = await this.db.queryOne<{ count: number | string }>(` + SELECT COUNT(*) as count FROM objects + WHERE object_type_name = ? + `, [typeName]); + // Ensure count is a number (PostgreSQL may return bigint as string) + if (!row?.count) return 0; + return typeof row.count === 'string' ? parseInt(row.count, 10) : Number(row.count); + } + + /** + * Search objects by label (case-insensitive) + */ + async searchByLabel( + typeName: CMDBObjectTypeName, + searchTerm: string, + options?: QueryOptions + ): Promise { + await this.ensureInitialized(); + const limit = options?.limit || 100; + const offset = options?.offset || 0; + + const likeOperator = this.isPostgres ? 'ILIKE' : 'LIKE'; + const rows = await this.db.query<{ id: string }>(` + SELECT id FROM objects + WHERE object_type_name = ? AND label ${likeOperator} ? + ORDER BY label ASC + LIMIT ? OFFSET ? + `, [typeName, `%${searchTerm}%`, limit, offset]); + + const objects = await Promise.all( + rows.map(row => this.reconstructObject(row.id, typeName)) + ); + + return objects.filter((obj): obj is T => obj !== null); + } + + /** + * Search across all object types + */ + async searchAllTypes(searchTerm: string, options?: QueryOptions): Promise { + await this.ensureInitialized(); + const limit = options?.limit || 100; + const offset = options?.offset || 0; + + const likeOperator = this.isPostgres ? 'ILIKE' : 'LIKE'; + const rows = await this.db.query<{ id: string; object_type_name: string }>(` + SELECT id, object_type_name FROM objects + WHERE label ${likeOperator} ? OR object_key ${likeOperator} ? + ORDER BY object_type_name, label ASC + LIMIT ? OFFSET ? + `, [`%${searchTerm}%`, `%${searchTerm}%`, limit, offset]); + + const objects = await Promise.all( + rows.map(row => this.reconstructObject(row.id, row.object_type_name as CMDBObjectTypeName)) + ); + + return objects.filter((obj): obj is CMDBObject => obj !== null); + } + + /** + * Upsert a single object + */ + async upsertObject(typeName: CMDBObjectTypeName, object: T): Promise { + await this.ensureInitialized(); + await this.normalizeObject(object, typeName); + } + + /** + * Batch upsert objects (much faster for bulk operations) + */ + async batchUpsertObjects(typeName: CMDBObjectTypeName, objects: T[]): Promise { + await this.ensureInitialized(); + if (objects.length === 0) return; + + await this.db.transaction(async (db) => { + for (const obj of objects) { + await this.normalizeObjectWithDb(obj, typeName, db); + } + }); + + logger.debug(`NormalizedCacheStore: Batch upserted ${objects.length} ${typeName} objects`); + } + + /** + * Delete an object by ID + */ + async deleteObject(typeName: CMDBObjectTypeName, id: string): Promise { + await this.ensureInitialized(); + + // Delete relations first (cascade should handle this, but explicit is safer) + await this.deleteRelationsForObject(id); + + // Delete attribute values (cascade should handle this) + await this.db.execute(` + DELETE FROM attribute_values WHERE object_id = ? + `, [id]); + + // Delete object + const changes = await this.db.execute(` + DELETE FROM objects + WHERE id = ? AND object_type_name = ? + `, [id, typeName]); + + return changes > 0; + } + + /** + * Clear all objects of a specific type + */ + async clearObjectType(typeName: CMDBObjectTypeName): Promise { + await this.ensureInitialized(); + + // Get all IDs first + const ids = await this.db.query<{ id: string }>(` + SELECT id FROM objects WHERE object_type_name = ? + `, [typeName]); + + // Delete relations and attribute values for each + for (const { id } of ids) { + await this.deleteRelationsForObject(id); + await this.db.execute(`DELETE FROM attribute_values WHERE object_id = ?`, [id]); + } + + // Delete objects + const changes = await this.db.execute(` + DELETE FROM objects WHERE object_type_name = ? + `, [typeName]); + + logger.info(`NormalizedCacheStore: Cleared ${changes} ${typeName} objects`); + return changes; + } + + /** + * Clear entire cache + */ + async clearAll(): Promise { + await this.ensureInitialized(); + await this.db.execute('DELETE FROM attribute_values'); + await this.db.execute('DELETE FROM object_relations'); + await this.db.execute('DELETE FROM objects'); + logger.info('NormalizedCacheStore: Cleared all cached data'); + } + + // ========================================================================== + // Normalization Logic + // ========================================================================== + + /** + * Normalize a CMDBObject to database format + */ + private async normalizeObject( + object: T, + typeName: CMDBObjectTypeName + ): Promise { + await this.db.transaction(async (db) => { + await this.normalizeObjectWithDb(object, typeName, db); + }); + } + + /** + * Normalize object using provided database adapter (for transactions) + */ + private async normalizeObjectWithDb( + object: T, + typeName: CMDBObjectTypeName, + db: DatabaseAdapter + ): Promise { + // Validate required fields + const objectId = object?.id ? String(object.id).trim() : null; + const objectKey = object?.objectKey ? String(object.objectKey).trim() : null; + const label = object?.label ? String(object.label).trim() : null; + + if (!objectId || objectId === 'null' || objectId === 'undefined') { + throw new Error(`Cannot normalize object: missing or invalid id. Object: ${JSON.stringify({ + id: object?.id, + objectKey: object?.objectKey, + label: object?.label, + typeName, + hasId: 'id' in (object || {}), + hasObjectKey: object && 'objectKey' in object, + hasLabel: object && 'label' in object, + objectKeys: object ? Object.keys(object) : [] + })}`); + } + + if (!objectKey || objectKey === 'null' || objectKey === 'undefined') { + throw new Error(`Cannot normalize object ${objectId}: missing or invalid objectKey. Object: ${JSON.stringify({ + id: objectId, + objectKey: object?.objectKey, + label: object?.label + })}`); + } + + if (!label || label === 'null' || label === 'undefined') { + throw new Error(`Cannot normalize object ${objectId}: missing or invalid label. Object: ${JSON.stringify({ + id: objectId, + objectKey: objectKey, + label: object?.label + })}`); + } + + // 1. Insert/update object record + + await db.execute(` + INSERT INTO objects (id, object_key, object_type_name, label, jira_updated_at, jira_created_at, cached_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + object_key = excluded.object_key, + label = excluded.label, + jira_updated_at = excluded.jira_updated_at, + cached_at = excluded.cached_at + `, [ + objectId, + objectKey, + typeName, + label, + object._jiraUpdatedAt || null, + object._jiraCreatedAt || null, + new Date().toISOString() + ]); + + // 2. Delete existing attribute values + await db.execute(` + DELETE FROM attribute_values WHERE object_id = ? + `, [objectId]); + + // 3. Get all attributes for this type (always sync all attributes) + const attributes = await schemaDiscoveryService.getAttributesForType(typeName); + + // 4. Store each attribute value + for (const attrDef of attributes) { + const value = (object as unknown as Record)[attrDef.fieldName]; + await this.storeAttributeValueWithDb(objectId, typeName, attrDef, value, db); + } + } + + /** + * Store attribute value based on type + */ + private async storeAttributeValue( + objectId: string, + typeName: CMDBObjectTypeName, + attrDef: AttributeDefinition, + value: unknown + ): Promise { + await this.db.transaction(async (db) => { + await this.storeAttributeValueWithDb(objectId, typeName, attrDef, value, db); + }); + } + + /** + * Store attribute value using provided database adapter + */ + private async storeAttributeValueWithDb( + objectId: string, + typeName: CMDBObjectTypeName, + attrDef: AttributeDefinition, + value: unknown, + db: DatabaseAdapter + ): Promise { + // Get attribute ID from database + const attrId = await schemaDiscoveryService.getAttributeId(typeName, attrDef.fieldName); + if (!attrId) { + logger.warn(`NormalizedCacheStore: Attribute not found: ${typeName}.${attrDef.fieldName}`); + return; + } + + // Handle null/undefined + if (value === null || value === undefined) { + return; // Don't store null values + } + + // Handle arrays + if (attrDef.isMultiple && Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + await this.insertAttributeValueWithDb(objectId, attrId, value[i], attrDef, i, db); + } + return; + } + + // Single value + await this.insertAttributeValueWithDb(objectId, attrId, value, attrDef, 0, db); + } + + /** + * Insert one attribute value row + */ + private async insertAttributeValueWithDb( + objectId: string, + attributeId: number, + value: unknown, + attrDef: AttributeDefinition, + arrayIndex: number, + db: DatabaseAdapter + ): Promise { + switch (attrDef.type) { + case 'reference': { + const ref = value as ObjectReference; + if (ref?.objectId) { + await db.execute(` + INSERT INTO attribute_values (object_id, attribute_id, reference_object_id, reference_object_key, reference_object_label, array_index) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (object_id, attribute_id, array_index) DO UPDATE SET + reference_object_id = excluded.reference_object_id, + reference_object_key = excluded.reference_object_key, + reference_object_label = excluded.reference_object_label + `, [objectId, attributeId, ref.objectId, ref.objectKey || '', ref.label || '', arrayIndex]); + } + break; + } + + case 'text': + case 'textarea': + case 'url': + case 'email': + case 'select': + case 'user': + case 'status': { + const textValue = String(value); + await db.execute(` + INSERT INTO attribute_values (object_id, attribute_id, text_value, array_index) + VALUES (?, ?, ?, ?) + ON CONFLICT (object_id, attribute_id, array_index) DO UPDATE SET + text_value = excluded.text_value + `, [objectId, attributeId, textValue, arrayIndex]); + break; + } + + case 'integer': + case 'float': { + // Special handling for confluenceSpace - it's defined as float but can be a URL string + if (attrDef.fieldName === 'confluenceSpace' && typeof value === 'string') { + // Store as text value for URL strings + await db.execute(` + INSERT INTO attribute_values (object_id, attribute_id, text_value, array_index) + VALUES (?, ?, ?, ?) + ON CONFLICT (object_id, attribute_id, array_index) DO UPDATE SET + text_value = excluded.text_value + `, [objectId, attributeId, value, arrayIndex]); + } else { + // Normal numeric handling + const numValue = Number(value); + if (!isNaN(numValue)) { + await db.execute(` + INSERT INTO attribute_values (object_id, attribute_id, number_value, array_index) + VALUES (?, ?, ?, ?) + ON CONFLICT (object_id, attribute_id, array_index) DO UPDATE SET + number_value = excluded.number_value + `, [objectId, attributeId, numValue, arrayIndex]); + } + } + break; + } + + case 'boolean': { + const boolValue = Boolean(value); + await db.execute(` + INSERT INTO attribute_values (object_id, attribute_id, boolean_value, array_index) + VALUES (?, ?, ?, ?) + ON CONFLICT (object_id, attribute_id, array_index) DO UPDATE SET + boolean_value = excluded.boolean_value + `, [objectId, attributeId, boolValue, arrayIndex]); + break; + } + + case 'date': { + const dateValue = value as string; // ISO date string + await db.execute(` + INSERT INTO attribute_values (object_id, attribute_id, date_value, array_index) + VALUES (?, ?, ?, ?) + ON CONFLICT (object_id, attribute_id, array_index) DO UPDATE SET + date_value = excluded.date_value + `, [objectId, attributeId, dateValue, arrayIndex]); + break; + } + + case 'datetime': { + const datetimeValue = value as string; // ISO datetime string + await db.execute(` + INSERT INTO attribute_values (object_id, attribute_id, datetime_value, array_index) + VALUES (?, ?, ?, ?) + ON CONFLICT (object_id, attribute_id, array_index) DO UPDATE SET + datetime_value = excluded.datetime_value + `, [objectId, attributeId, datetimeValue, arrayIndex]); + break; + } + + default: { + // Fallback to text + const textValue = String(value); + await db.execute(` + INSERT INTO attribute_values (object_id, attribute_id, text_value, array_index) + VALUES (?, ?, ?, ?) + ON CONFLICT (object_id, attribute_id, array_index) DO UPDATE SET + text_value = excluded.text_value + `, [objectId, attributeId, textValue, arrayIndex]); + } + } + } + + // ========================================================================== + // Reconstruction Logic + // ========================================================================== + + /** + * Reconstruct CMDBObject from normalized data + */ + private async reconstructObject( + objectId: string, + typeName: CMDBObjectTypeName + ): Promise { + // 1. Get object record + const objRow = await this.db.queryOne<{ + id: string; + object_key: string; + label: string; + jira_updated_at: string | null; + jira_created_at: string | null; + }>(` + SELECT id, object_key, label, jira_updated_at, jira_created_at + FROM objects + WHERE id = ? AND object_type_name = ? + `, [objectId, typeName]); + + if (!objRow) return null; + + // 2. Load all attribute values + const attributeValues = await this.loadAttributeValues(objectId, typeName); + + // 3. Build CMDBObject + const result: Record = { + id: objRow.id, + objectKey: objRow.object_key, + label: objRow.label, + _objectType: typeName, + _jiraUpdatedAt: objRow.jira_updated_at || new Date().toISOString(), + _jiraCreatedAt: objRow.jira_created_at || new Date().toISOString(), + ...attributeValues + }; + + return result as T; + } + + /** + * Load all attribute values for an object + */ + private async loadAttributeValues( + objectId: string, + typeName: CMDBObjectTypeName + ): Promise> { + // Get all attribute values + const valueRows = await this.db.query<{ + attribute_id: number; + field_name: string; + attr_type: string; + is_multiple: boolean | number; + text_value: string | null; + number_value: number | null; + boolean_value: boolean | number | null; + date_value: string | null; + datetime_value: string | null; + reference_object_id: string | null; + reference_object_key: string | null; + reference_object_label: string | null; + array_index: number; + }>(` + SELECT + av.attribute_id, + a.field_name, + a.attr_type, + a.is_multiple, + av.text_value, + av.number_value, + av.boolean_value, + av.date_value, + av.datetime_value, + av.reference_object_id, + av.reference_object_key, + av.reference_object_label, + av.array_index + FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = ? + ORDER BY a.field_name, av.array_index + `, [objectId]); + + // Group by field name + const result: Record = {}; + + for (const row of valueRows) { + const fieldName = row.field_name; + const isMultiple = typeof row.is_multiple === 'boolean' ? row.is_multiple : row.is_multiple === 1; + + // Get value based on type + let value: unknown = null; + switch (row.attr_type) { + case 'reference': + if (row.reference_object_id) { + // Try to load referenced object details from cache first + const refObj = await this.getObjectById(row.reference_object_id); + if (refObj) { + value = { + objectId: refObj.id, + objectKey: refObj.objectKey, + label: refObj.label, + }; + } else { + // Fallback: use stored reference metadata if available + // This handles cases where referenced objects aren't in cache + // For data stored before migration, these might be NULL, so we'll have empty labels + const storedKey = row.reference_object_key; + const storedLabel = row.reference_object_label; + + // If stored metadata is missing, try to look up the object by ID in objects table + // (it might exist but not be in the reference cache) + if ((!storedKey || !storedLabel) && row.reference_object_id) { + const objRow = await this.db.queryOne<{ + object_key: string; + label: string; + }>(` + SELECT object_key, label + FROM objects + WHERE id = ? + `, [row.reference_object_id]); + + if (objRow) { + value = { + objectId: row.reference_object_id, + objectKey: objRow.object_key, + label: objRow.label, + }; + // Update the stored metadata for future use + await this.db.execute(` + UPDATE attribute_values + SET reference_object_key = ?, reference_object_label = ? + WHERE object_id = ? AND attribute_id = ? AND array_index = ? + `, [objRow.object_key, objRow.label, objectId, row.attribute_id, row.array_index]).catch(() => { + // Ignore update errors + }); + } else { + // No metadata available - try to fetch from Jira if token is available + if (jiraAssetsClient.hasToken()) { + try { + const jiraObj = await jiraAssetsClient.getObject(row.reference_object_id); + if (jiraObj) { + // Store the object in cache with minimal info + await this.db.execute(` + INSERT INTO objects (id, object_key, object_type_name, label, cached_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + object_key = excluded.object_key, + label = excluded.label, + cached_at = excluded.cached_at + `, [ + jiraObj.id.toString(), + jiraObj.objectKey || '', + 'Unknown', + jiraObj.label || '', + new Date().toISOString() + ]); + + // Update reference cache + this.referenceCache.set(row.reference_object_id, { + id: jiraObj.id.toString(), + objectKey: jiraObj.objectKey || '', + label: jiraObj.label || '', + }); + + // Update stored metadata for future use + await this.db.execute(` + UPDATE attribute_values + SET reference_object_key = ?, reference_object_label = ? + WHERE object_id = ? AND attribute_id = ? AND array_index = ? + `, [jiraObj.objectKey || '', jiraObj.label || '', objectId, row.attribute_id, row.array_index]).catch(() => { + // Ignore update errors + }); + + // Use the fetched label + value = { + objectId: row.reference_object_id, + objectKey: jiraObj.objectKey || '', + label: jiraObj.label || '', + }; + } else { + // Fetch failed - use stored metadata or fallback + value = { + objectId: row.reference_object_id, + objectKey: storedKey || '', + label: storedLabel || storedKey || '', + }; + } + } catch (error) { + logger.debug(`NormalizedCacheStore: Failed to fetch referenced object ${row.reference_object_id} from Jira`, error); + // Use stored metadata or fallback + value = { + objectId: row.reference_object_id, + objectKey: storedKey || '', + label: storedLabel || storedKey || '', + }; + } + } else { + // No token available - use stored metadata or fallback + value = { + objectId: row.reference_object_id, + objectKey: storedKey || '', + label: storedLabel || storedKey || '', + }; + } + } + } else { + // Use stored metadata + value = { + objectId: row.reference_object_id, + objectKey: storedKey || '', + label: storedLabel || '', + }; + } + } + } + break; + case 'text': + case 'textarea': + case 'url': + case 'email': + case 'select': + case 'user': + case 'status': + value = row.text_value; + break; + case 'integer': + case 'float': + // Special handling for confluenceSpace - check both text_value and number_value + // It's defined as float but can be a URL string + if (fieldName === 'confluenceSpace') { + // Prefer text_value (for URL strings), fallback to number_value (for legacy numeric values) + value = row.text_value !== null ? row.text_value : (row.number_value !== null ? String(row.number_value) : null); + } else { + value = row.number_value; + } + break; + case 'boolean': + // Handle SQLite (integer) vs PostgreSQL (boolean) + value = typeof row.boolean_value === 'boolean' ? row.boolean_value : row.boolean_value === 1; + break; + case 'date': + value = row.date_value; + break; + case 'datetime': + value = row.datetime_value; + break; + default: + value = row.text_value; + } + + // Handle arrays vs single values + if (isMultiple) { + if (!result[fieldName]) { + result[fieldName] = []; + } + (result[fieldName] as unknown[]).push(value); + } else { + result[fieldName] = value; + } + } + + // Post-process: For reference fields with missing labels, try to look up the referenced objects + // This is especially important for Application Functions and other reference data + for (const [fieldName, fieldValue] of Object.entries(result)) { + if (fieldValue && typeof fieldValue === 'object') { + // Check if it's an array of references + if (Array.isArray(fieldValue)) { + const refs = fieldValue as Array<{ objectId?: string; objectKey?: string; label?: string }>; + for (let i = 0; i < refs.length; i++) { + const ref = refs[i]; + if (ref && ref.objectId) { + // If label is missing or empty, try to populate it + if (!ref.label || ref.label === '') { + // Try to look up the referenced object from cache first + let refObj = await this.getObjectById(ref.objectId); + + // If not in cache, try to fetch from Jira + if (!refObj && jiraAssetsClient.hasToken()) { + try { + const jiraObj = await jiraAssetsClient.getObject(ref.objectId); + if (jiraObj) { + // Parse and cache the object (we don't know the type, so we'll just store minimal info) + // The object will be stored in the objects table with minimal info + await this.db.execute(` + INSERT INTO objects (id, object_key, object_type_name, label, cached_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + object_key = excluded.object_key, + label = excluded.label, + cached_at = excluded.cached_at + `, [ + jiraObj.id.toString(), + jiraObj.objectKey || '', + 'Unknown', // Type unknown, but we'll store it anyway + jiraObj.label || '', + new Date().toISOString() + ]); + + // Update reference cache + this.referenceCache.set(ref.objectId, { + id: jiraObj.id.toString(), + objectKey: jiraObj.objectKey || '', + label: jiraObj.label || '', + }); + + refObj = { + id: jiraObj.id.toString(), + objectKey: jiraObj.objectKey || '', + label: jiraObj.label || '', + }; + + // Update stored metadata for future use (for all matching rows) + const attrId = await schemaDiscoveryService.getAttributeId(typeName, fieldName); + if (attrId) { + await this.db.execute(` + UPDATE attribute_values + SET reference_object_key = ?, reference_object_label = ? + WHERE object_id = ? AND attribute_id = ? AND reference_object_id = ? + `, [jiraObj.objectKey || '', jiraObj.label || '', objectId, attrId, ref.objectId]).catch(() => { + // Ignore update errors + }); + } + } + } catch (error) { + logger.debug(`NormalizedCacheStore: Failed to fetch referenced object ${ref.objectId} from Jira`, error); + } + } + + if (refObj) { + refs[i] = { + objectId: refObj.id, + objectKey: refObj.objectKey || ref.objectKey || '', + label: refObj.label, + }; + } else if (ref.objectKey) { + // Use objectKey as fallback label (better than objectId) + refs[i] = { + objectId: ref.objectId, + objectKey: ref.objectKey, + label: ref.objectKey, + }; + } else { + // Last resort: use objectId as label (but log a warning) + logger.warn(`NormalizedCacheStore: Reference ${fieldName}[${i}] has no label, objectKey, or cached object (objectId: ${ref.objectId})`); + refs[i] = { + objectId: ref.objectId, + objectKey: ref.objectKey || '', + label: ref.objectId, + }; + } + } + } + } + } else if (fieldValue && typeof fieldValue === 'object' && 'objectId' in fieldValue) { + // Single reference + const ref = fieldValue as { objectId?: string; objectKey?: string; label?: string }; + if (ref.objectId && (!ref.label || ref.label === '')) { + // Try to look up the referenced object from cache first + let refObj = await this.getObjectById(ref.objectId); + + // If not in cache, try to fetch from Jira + if (!refObj && jiraAssetsClient.hasToken()) { + try { + const jiraObj = await jiraAssetsClient.getObject(ref.objectId); + if (jiraObj) { + // Parse and cache the object (we don't know the type, so we'll just store minimal info) + await this.db.execute(` + INSERT INTO objects (id, object_key, object_type_name, label, cached_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + object_key = excluded.object_key, + label = excluded.label, + cached_at = excluded.cached_at + `, [ + jiraObj.id.toString(), + jiraObj.objectKey || '', + 'Unknown', // Type unknown, but we'll store it anyway + jiraObj.label || '', + new Date().toISOString() + ]); + + // Update reference cache + this.referenceCache.set(ref.objectId, { + id: jiraObj.id.toString(), + objectKey: jiraObj.objectKey || '', + label: jiraObj.label || '', + }); + + refObj = { + id: jiraObj.id.toString(), + objectKey: jiraObj.objectKey || '', + label: jiraObj.label || '', + }; + + // Update stored metadata for future use + const attrId = await schemaDiscoveryService.getAttributeId(typeName, fieldName); + if (attrId) { + await this.db.execute(` + UPDATE attribute_values + SET reference_object_key = ?, reference_object_label = ? + WHERE object_id = ? AND attribute_id = ? AND reference_object_id = ? + `, [jiraObj.objectKey || '', jiraObj.label || '', objectId, attrId, ref.objectId]).catch(() => { + // Ignore update errors + }); + } + } + } catch (error) { + logger.debug(`NormalizedCacheStore: Failed to fetch referenced object ${ref.objectId} from Jira`, error); + } + } + + if (refObj) { + result[fieldName] = { + objectId: refObj.id, + objectKey: refObj.objectKey || ref.objectKey || '', + label: refObj.label, + }; + } else if (ref.objectKey) { + // Use objectKey as fallback label (better than objectId) + result[fieldName] = { + objectId: ref.objectId, + objectKey: ref.objectKey, + label: ref.objectKey, + }; + } else { + // Last resort: use objectId as label (but log a warning) + logger.warn(`NormalizedCacheStore: Reference ${fieldName} has no label, objectKey, or cached object (objectId: ${ref.objectId})`); + result[fieldName] = { + objectId: ref.objectId, + objectKey: ref.objectKey || '', + label: ref.objectId, + }; + } + } + } + } + } + + return result; + } + + /** + * Helper to get object by ID (for references) + */ + private async getObjectById(id: string): Promise<{ + id: string; + objectKey: string; + label: string; + } | null> { + // Check cache first + const cached = this.referenceCache.get(id); + if (cached) return cached; + + const row = await this.db.queryOne<{ + id: string; + object_key: string; + label: string; + }>(` + SELECT id, object_key, label + FROM objects + WHERE id = ? + `, [id]); + + if (row) { + this.referenceCache.set(id, row); + return row; + } + + return null; + } + + // ========================================================================== + // Query Operations + // ========================================================================== + + /** + * Query objects with filters (generic) + */ + async queryWithFilters( + typeName: CMDBObjectTypeName, + filters: Record, + options?: QueryOptions + ): Promise<{ objects: T[]; total: number }> { + await this.ensureInitialized(); + + // Build WHERE clause + const { whereClause, params: whereParams } = await queryBuilder.buildWhereClause(filters, typeName); + + // Build ORDER BY + const orderBy = queryBuilder.buildOrderBy(options?.orderBy, options?.orderDir); + + // Build pagination + const pagination = queryBuilder.buildPagination(options?.limit, options?.offset); + + // Count query + const countQuery = ` + SELECT COUNT(DISTINCT o.id) as count + FROM objects o + ${whereClause} + `; + + // Data query + const dataQuery = ` + SELECT DISTINCT o.id + FROM objects o + ${whereClause} + ${orderBy} + ${pagination} + `; + + try { + // Execute both queries + const [countResult, dataResult] = await Promise.all([ + this.db.queryOne<{ count: number }>(countQuery, whereParams), + this.db.query<{ id: string }>(dataQuery, whereParams), + ]); + + const total = countResult?.count || 0; + + // Reconstruct objects + const objects = await Promise.all( + dataResult.map(row => this.reconstructObject(row.id, typeName)) + ); + + return { + objects: objects.filter((obj): obj is T => obj !== null), + total + }; + } catch (error) { + logger.error(`NormalizedCacheStore: Query failed for ${typeName}`, error); + return { objects: [], total: 0 }; + } + } + + // ========================================================================== + // Relation Operations + // ========================================================================== + + /** + * Store a relation between two objects + */ + async upsertRelation( + sourceId: string, + targetId: string, + attributeName: string, + sourceType: string, + targetType: string + ): Promise { + await this.ensureInitialized(); + + // Get attribute ID + const attrId = await schemaDiscoveryService.getAttributeId(sourceType, attributeName); + if (!attrId) { + logger.warn(`NormalizedCacheStore: Attribute not found for relation: ${attributeName}`); + return; + } + + await this.db.execute(` + INSERT INTO object_relations (source_id, target_id, attribute_id, source_type, target_type) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(source_id, target_id, attribute_id) DO UPDATE SET + source_type = excluded.source_type, + target_type = excluded.target_type + `, [sourceId, targetId, attrId, sourceType, targetType]); + } + + /** + * Batch upsert relations + * attributeName can be either fieldName (camelCase) or name (display name) + */ + async batchUpsertRelations(relations: Array<{ + sourceId: string; + targetId: string; + attributeName: string; // Can be fieldName or name + sourceType: string; + targetType: string; + }>): Promise { + await this.ensureInitialized(); + if (relations.length === 0) return; + + // Schema discovery must be manually triggered via API endpoints + // No automatic discovery + + let storedCount = 0; + let skippedCount = 0; + + // Filter relations first - check which objects exist BEFORE starting transaction + // This prevents transaction abort issues in PostgreSQL + const validRelations: Array<{ + rel: typeof relations[0]; + attrId: number; + }> = []; + + for (const rel of relations) { + // getAttributeId now supports both fieldName and name + const attrId = await schemaDiscoveryService.getAttributeId(rel.sourceType, rel.attributeName); + if (!attrId) { + logger.warn(`NormalizedCacheStore: Attribute not found for relation: ${rel.sourceType}.${rel.attributeName} (skipping relation from ${rel.sourceId} to ${rel.targetId})`); + skippedCount++; + continue; + } + + // Check if both source and target objects exist (outside transaction to avoid abort issues) + const sourceExists = await this.db.queryOne<{ count: number }>(` + SELECT COUNT(*) as count FROM objects WHERE id = ? + `, [rel.sourceId]); + + const targetExists = await this.db.queryOne<{ count: number }>(` + SELECT COUNT(*) as count FROM objects WHERE id = ? + `, [rel.targetId]); + + if (!sourceExists || sourceExists.count === 0) { + logger.warn(`NormalizedCacheStore: Source object ${rel.sourceId} does not exist yet (skipping relation to ${rel.targetId})`); + skippedCount++; + continue; + } + + if (!targetExists || targetExists.count === 0) { + logger.debug(`NormalizedCacheStore: Target object ${rel.targetId} does not exist yet (skipping relation from ${rel.sourceId} - will be inserted later when target is synced)`); + skippedCount++; + continue; + } + + validRelations.push({ rel, attrId }); + } + + // Now insert only valid relations - process each in its own transaction + // to avoid transaction abort issues if a race condition occurs + for (const { rel, attrId } of validRelations) { + try { + await this.db.transaction(async (db) => { + await db.execute(` + INSERT INTO object_relations (source_id, target_id, attribute_id, source_type, target_type) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(source_id, target_id, attribute_id) DO UPDATE SET + source_type = excluded.source_type, + target_type = excluded.target_type + `, [rel.sourceId, rel.targetId, attrId, rel.sourceType, rel.targetType]); + }); + storedCount++; + } catch (error: any) { + // If foreign key constraint fails (race condition where object was deleted between check and insert), skip + if (error?.code === '23503' || error?.message?.includes('foreign key constraint')) { + logger.debug(`NormalizedCacheStore: Foreign key constraint violation for relation from ${rel.sourceId} to ${rel.targetId} (target may not exist yet - will be retried when target is synced)`); + skippedCount++; + } else { + // Re-throw unexpected errors - they will be handled by caller + throw error; + } + } + } + + if (skippedCount > 0) { + logger.warn(`NormalizedCacheStore: Batch upserted ${storedCount}/${relations.length} relations (${skippedCount} skipped due to missing attributes)`); + } else { + logger.debug(`NormalizedCacheStore: Batch upserted ${relations.length} relations`); + } + } + + /** + * Get related objects (outbound references from an object) + */ + async getRelatedObjects( + sourceId: string, + targetTypeName: CMDBObjectTypeName, + attributeName?: string + ): Promise { + await this.ensureInitialized(); + + let query = ` + SELECT DISTINCT o.id + FROM objects o + JOIN object_relations rel ON o.id = rel.target_id + WHERE rel.source_id = ? AND o.object_type_name = ? + `; + const params: (string | undefined)[] = [sourceId, targetTypeName]; + + if (attributeName) { + // Get attribute ID + const sourceTypeRow = await this.db.queryOne<{ object_type_name: string }>(` + SELECT object_type_name FROM objects WHERE id = ? + `, [sourceId]); + + if (sourceTypeRow) { + const attrId = await schemaDiscoveryService.getAttributeId(sourceTypeRow.object_type_name, attributeName); + if (attrId) { + query += ' AND rel.attribute_id = ?'; + params.push(String(attrId)); + } + } + } + + const rows = await this.db.query<{ id: string }>(query, params); + + const objects = await Promise.all( + rows.map(row => this.reconstructObject(row.id, targetTypeName)) + ); + + return objects.filter((obj): obj is T => obj !== null); + } + + /** + * Get objects that reference the given object (inbound references) + */ + async getReferencingObjects( + targetId: string, + sourceTypeName: CMDBObjectTypeName, + attributeName?: string + ): Promise { + await this.ensureInitialized(); + + logger.debug(`NormalizedCacheStore: getReferencingObjects - targetId: ${targetId}, sourceType: ${sourceTypeName}, attributeName: ${attributeName || 'none'}`); + + let query = ` + SELECT DISTINCT o.id + FROM objects o + JOIN object_relations rel ON o.id = rel.source_id + WHERE rel.target_id = ? AND o.object_type_name = ? + `; + const params: (string | undefined)[] = [targetId, sourceTypeName]; + + if (attributeName) { + const attrId = await schemaDiscoveryService.getAttributeId(sourceTypeName, attributeName); + if (attrId) { + query += ' AND rel.attribute_id = ?'; + params.push(String(attrId)); + } + } + + const rows = await this.db.query<{ id: string }>(query, params); + + logger.debug(`NormalizedCacheStore: Query: ${query.replace(/\?/g, (_, idx) => JSON.stringify(params[idx]))}`); + logger.debug(`NormalizedCacheStore: Found ${rows.length} referencing objects for ${targetId} of type ${sourceTypeName}`); + + // Debug: Check if any relations exist at all for this target + if (rows.length === 0) { + const debugQuery = ` + SELECT COUNT(*) as count + FROM object_relations + WHERE target_id = ? + `; + const debugRows = await this.db.query<{ count: number }>(debugQuery, [targetId]); + logger.debug(`NormalizedCacheStore: Total relations for target ${targetId}: ${debugRows[0]?.count || 0}`); + + // Also check relations with the source type + const debugQuery2 = ` + SELECT COUNT(*) as count + FROM object_relations rel + JOIN objects o ON o.id = rel.source_id + WHERE rel.target_id = ? AND o.object_type_name = ? + `; + const debugRows2 = await this.db.query<{ count: number }>(debugQuery2, [targetId, sourceTypeName]); + logger.debug(`NormalizedCacheStore: Relations for target ${targetId} with source type ${sourceTypeName}: ${debugRows2[0]?.count || 0}`); + } + + const objects = await Promise.all( + rows.map(row => this.reconstructObject(row.id, sourceTypeName)) + ); + + const validObjects = objects.filter((obj): obj is T => obj !== null); + logger.debug(`NormalizedCacheStore: Successfully reconstructed ${validObjects.length} objects`); + + return validObjects; + } + + /** + * Delete all relations for an object + */ + async deleteRelationsForObject(objectId: string): Promise { + await this.ensureInitialized(); + await this.db.execute(` + DELETE FROM object_relations + WHERE source_id = ? OR target_id = ? + `, [objectId, objectId]); + } + + /** + * Extract and store relations from an object based on its type schema + * Uses discovered schema from database, not hardcoded schema + */ + async extractAndStoreRelations(typeName: CMDBObjectTypeName, object: T): Promise { + await this.ensureInitialized(); + + // Schema discovery must be manually triggered via API endpoints + // No automatic discovery + + // Get reference attributes from DISCOVERED schema (not hardcoded) + const allAttributes = await schemaDiscoveryService.getAttributesForType(typeName); + const refAttributes = allAttributes.filter(a => a.type === 'reference'); + + if (refAttributes.length === 0) { + logger.debug(`NormalizedCacheStore: No reference attributes found for ${typeName}`); + return; + } + + const relations: Array<{ + sourceId: string; + targetId: string; + attributeName: string; // Use fieldName for lookup (getAttributeId supports both) + 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.fieldName, // Use fieldName - getAttributeId supports both fieldName and 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.fieldName, // Use fieldName - getAttributeId supports both fieldName and name + sourceType: typeName, + targetType, + }); + } + } + } + + if (relations.length > 0) { + logger.debug(`NormalizedCacheStore: Extracting ${relations.length} relations from ${typeName} object ${object.objectKey || object.id}`); + await this.batchUpsertRelations(relations); + } + } + + // ========================================================================== + // Sync Metadata Operations + // ========================================================================== + + /** + * Get sync metadata value + */ + async getSyncMetadata(key: string): Promise { + await this.ensureInitialized(); + const row = await this.db.queryOne<{ value: string }>(` + SELECT value FROM sync_metadata WHERE key = ? + `, [key]); + return row?.value || null; + } + + /** + * Set sync metadata value + */ + async setSyncMetadata(key: string, value: string): Promise { + await this.ensureInitialized(); + await this.db.execute(` + INSERT INTO sync_metadata (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at + `, [key, value, new Date().toISOString()]); + } + + /** + * Delete sync metadata + */ + async deleteSyncMetadata(key: string): Promise { + await this.ensureInitialized(); + await this.db.execute(` + DELETE FROM sync_metadata WHERE key = ? + `, [key]); + } + + // ========================================================================== + // Statistics + // ========================================================================== + + /** + * Get cache statistics + */ + async getStats(): Promise { + await this.ensureInitialized(); + + // Count by type + const typeCounts = await this.db.query<{ object_type_name: string; count: number | string }>(` + SELECT object_type_name, COUNT(*) as count + FROM objects + GROUP BY object_type_name + `); + + const objectsByType: Record = {}; + let totalObjects = 0; + for (const { object_type_name, count } of typeCounts) { + // Ensure count is a number (PostgreSQL may return bigint as string) + const numCount = typeof count === 'string' ? parseInt(count, 10) : Number(count); + objectsByType[object_type_name] = numCount; + totalObjects += numCount; + } + + // Count relations - count distinct reference values in attribute_values + // This is more accurate than just counting object_relations table + // because not all references may have been extracted to object_relations yet + let relCount = 0; + + if (this.isPostgres) { + // PostgreSQL: use string concatenation + const relCountRow = await this.db.queryOne<{ count: number | string }>(` + SELECT COUNT(DISTINCT object_id || '-' || attribute_id || '-' || COALESCE(array_index::text, '0')) as count + FROM attribute_values + WHERE reference_object_id IS NOT NULL + `); + if (relCountRow?.count) { + relCount = typeof relCountRow.count === 'string' ? parseInt(relCountRow.count, 10) : Number(relCountRow.count); + } + } else { + // SQLite: use || for concatenation + const relCountRow = await this.db.queryOne<{ count: number | string }>(` + SELECT COUNT(DISTINCT object_id || '-' || attribute_id || '-' || COALESCE(array_index, 0)) as count + FROM attribute_values + WHERE reference_object_id IS NOT NULL + `); + if (relCountRow?.count) { + relCount = typeof relCountRow.count === 'string' ? parseInt(relCountRow.count, 10) : Number(relCountRow.count); + } + } + + // Fallback: also count object_relations if attribute_values count is 0 + if (relCount === 0) { + const relTableCount = await this.db.queryOne<{ count: number | string }>(` + SELECT COUNT(*) as count FROM object_relations + `); + relCount = relTableCount?.count + ? (typeof relTableCount.count === 'string' ? parseInt(relTableCount.count, 10) : Number(relTableCount.count)) + : 0; + } + + // Get sync metadata + const lastFullSync = await this.getSyncMetadata('lastFullSync'); + const lastIncrementalSync = await this.getSyncMetadata('lastIncrementalSync'); + + // Check if cache is warm (has Application Components) + const isWarm = (objectsByType['ApplicationComponent'] || 0) > 0; + + // Get database size + let dbSizeBytes = 0; + if (this.db.getSizeBytes) { + dbSizeBytes = await this.db.getSizeBytes(); + } + + return { + totalObjects, + objectsByType, + totalRelations: relCount, + lastFullSync, + lastIncrementalSync, + isWarm, + dbSizeBytes, + }; + } + + /** + * Check if cache is warm (has data) + */ + async isWarm(): Promise { + await this.ensureInitialized(); + const count = await this.countObjects('ApplicationComponent'); + return count > 0; + } + + /** + * Get count of broken references (references to objects that don't exist) + */ + async getBrokenReferencesCount(): Promise { + await this.ensureInitialized(); + const result = await this.db.queryOne<{ count: number | string }>(` + SELECT COUNT(*) as count + FROM attribute_values av + WHERE av.reference_object_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM objects o WHERE o.id = av.reference_object_id + ) + `); + // Ensure count is a number (PostgreSQL may return bigint as string) + if (!result?.count) return 0; + return typeof result.count === 'string' ? parseInt(result.count, 10) : Number(result.count); + } + + /** + * Get list of broken references with details + */ + async getBrokenReferences(limit: number = 50, offset: number = 0): Promise> { + await this.ensureInitialized(); + return await this.db.query<{ + object_id: string; + attribute_id: number; + reference_object_id: string; + field_name: string; + object_type_name: string; + object_key: string; + label: string; + }>(` + SELECT + av.object_id, + av.attribute_id, + av.reference_object_id, + a.field_name, + o.object_type_name, + o.object_key, + o.label + FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + JOIN objects o ON av.object_id = o.id + WHERE av.reference_object_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM objects o2 WHERE o2.id = av.reference_object_id + ) + ORDER BY o.object_type_name, o.object_key + LIMIT ? OFFSET ? + `, [limit, offset]); + } + + /** + * Get object metadata by ID (for inspection) + */ + async getObjectMetadata(id: string): Promise<{ + id: string; + object_key: string; + object_type_name: string; + label: string; + } | null> { + await this.ensureInitialized(); + return await this.db.queryOne<{ + id: string; + object_key: string; + object_type_name: string; + label: string; + }>(` + SELECT id, object_key, object_type_name, label + FROM objects + WHERE id = ? + `, [id]); + } + + /** + * Close database connection + */ + async close(): Promise { + await this.db.close(); + } +} + +// Export singleton instance +export const normalizedCacheStore = new NormalizedCacheStore(); diff --git a/backend/src/services/queryBuilder.ts b/backend/src/services/queryBuilder.ts new file mode 100644 index 0000000..aa0f481 --- /dev/null +++ b/backend/src/services/queryBuilder.ts @@ -0,0 +1,277 @@ +/** + * Generic Query Builder + * + * Builds SQL queries dynamically based on filters and schema. + */ + +import { logger } from './logger.js'; +import { schemaDiscoveryService } from './schemaDiscoveryService.js'; +import type { CMDBObjectTypeName } from '../generated/jira-types.js'; +import type { AttributeDefinition } from '../generated/jira-schema.js'; + +class QueryBuilder { + /** + * Build WHERE clause from filters + */ + async buildWhereClause( + filters: Record, + typeName: CMDBObjectTypeName + ): Promise<{ whereClause: string; params: unknown[] }> { + const conditions: string[] = ['o.object_type_name = ?']; + const params: unknown[] = [typeName]; + let paramIndex = 2; + + for (const [fieldName, filterValue] of Object.entries(filters)) { + if (filterValue === undefined || filterValue === null) continue; + + const attrDef = await schemaDiscoveryService.getAttribute(typeName, fieldName); + if (!attrDef) { + logger.debug(`QueryBuilder: Unknown field ${fieldName} for type ${typeName}, skipping`); + continue; + } + + const condition = this.buildFilterCondition(fieldName, filterValue, attrDef, paramIndex); + if (condition.condition) { + conditions.push(condition.condition); + params.push(...condition.params); + paramIndex += condition.params.length; + } + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + return { whereClause, params }; + } + + /** + * Build filter condition for one field + */ + buildFilterCondition( + fieldName: string, + filterValue: unknown, + attrDef: AttributeDefinition, + startParamIndex: number + ): { condition: string; params: unknown[] } { + // Handle special operators + if (typeof filterValue === 'object' && filterValue !== null && !Array.isArray(filterValue)) { + const filterObj = filterValue as Record; + + // Exists check + if (filterObj.exists === true) { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id AND a.field_name = ? + )`, + params: [fieldName] + }; + } + + // Empty check + if (filterObj.empty === true) { + return { + condition: `NOT EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id AND a.field_name = ? + )`, + params: [fieldName] + }; + } + + // Contains (text search) + if (filterObj.contains !== undefined && typeof filterObj.contains === 'string') { + if (attrDef.type === 'text' || attrDef.type === 'textarea') { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND LOWER(av.text_value) LIKE LOWER(?) + )`, + params: [fieldName, `%${filterObj.contains}%`] + }; + } + } + + // Reference filters + if (attrDef.type === 'reference') { + if (filterObj.objectId !== undefined) { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND av.reference_object_id = ? + )`, + params: [fieldName, String(filterObj.objectId)] + }; + } + if (filterObj.objectKey !== undefined) { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + JOIN objects ref_obj ON av.reference_object_id = ref_obj.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND ref_obj.object_key = ? + )`, + params: [fieldName, String(filterObj.objectKey)] + }; + } + if (filterObj.label !== undefined) { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + JOIN objects ref_obj ON av.reference_object_id = ref_obj.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND LOWER(ref_obj.label) = LOWER(?) + )`, + params: [fieldName, String(filterObj.label)] + }; + } + } + } + + // Handle array filters (for multiple reference fields) + if (attrDef.isMultiple && Array.isArray(filterValue)) { + if (attrDef.type === 'reference') { + const conditions: string[] = []; + const params: unknown[] = []; + for (const val of filterValue) { + if (typeof val === 'object' && val !== null) { + const ref = val as { objectId?: string; objectKey?: string }; + if (ref.objectId) { + conditions.push(`EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND av.reference_object_id = ? + )`); + params.push(fieldName, ref.objectId); + } else if (ref.objectKey) { + conditions.push(`EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + JOIN objects ref_obj ON av.reference_object_id = ref_obj.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND ref_obj.object_key = ? + )`); + params.push(fieldName, ref.objectKey); + } + } + } + if (conditions.length > 0) { + return { condition: `(${conditions.join(' OR ')})`, params }; + } + } + } + + // Simple value filters + if (attrDef.type === 'reference') { + if (typeof filterValue === 'object' && filterValue !== null) { + const ref = filterValue as { objectId?: string; objectKey?: string; label?: string }; + if (ref.objectId) { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND av.reference_object_id = ? + )`, + params: [fieldName, ref.objectId] + }; + } else if (ref.objectKey) { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + JOIN objects ref_obj ON av.reference_object_id = ref_obj.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND ref_obj.object_key = ? + )`, + params: [fieldName, ref.objectKey] + }; + } else if (ref.label) { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + JOIN objects ref_obj ON av.reference_object_id = ref_obj.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND LOWER(ref_obj.label) = LOWER(?) + )`, + params: [fieldName, ref.label] + }; + } + } + } else if (attrDef.type === 'text' || attrDef.type === 'textarea' || attrDef.type === 'url' || attrDef.type === 'email' || attrDef.type === 'select' || attrDef.type === 'user' || attrDef.type === 'status') { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND av.text_value = ? + )`, + params: [fieldName, String(filterValue)] + }; + } else if (attrDef.type === 'integer' || attrDef.type === 'float') { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND av.number_value = ? + )`, + params: [fieldName, Number(filterValue)] + }; + } else if (attrDef.type === 'boolean') { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND av.boolean_value = ? + )`, + params: [fieldName, Boolean(filterValue)] + }; + } + + return { condition: '', params: [] }; + } + + /** + * Build ORDER BY clause + */ + buildOrderBy(orderBy?: string, orderDir?: 'ASC' | 'DESC'): string { + const safeOrderBy = ['id', 'object_key', 'object_type_name', 'label', 'cached_at'].includes(orderBy || '') + ? (orderBy || 'label') + : 'label'; + const safeOrderDir = orderDir === 'DESC' ? 'DESC' : 'ASC'; + return `ORDER BY o.${safeOrderBy} ${safeOrderDir}`; + } + + /** + * Build pagination clause + */ + buildPagination(limit?: number, offset?: number): string { + const limitValue = limit || 100; + const offsetValue = offset || 0; + return `LIMIT ${limitValue} OFFSET ${offsetValue}`; + } +} + +export const queryBuilder = new QueryBuilder(); diff --git a/backend/src/services/schemaCacheService.ts b/backend/src/services/schemaCacheService.ts new file mode 100644 index 0000000..0188cc2 --- /dev/null +++ b/backend/src/services/schemaCacheService.ts @@ -0,0 +1,256 @@ +/** + * Schema Cache Service + * + * In-memory cache for schema data with TTL support. + * Provides fast access to schema information without hitting the database on every request. + */ + +import { logger } from './logger.js'; +import { schemaDiscoveryService } from './schemaDiscoveryService.js'; +import type { ObjectTypeDefinition, AttributeDefinition } from '../generated/jira-schema.js'; +import { getDatabaseAdapter } from './database/singleton.js'; + +interface SchemaResponse { + metadata: { + generatedAt: string; + objectTypeCount: number; + totalAttributes: number; + enabledObjectTypeCount?: number; + }; + objectTypes: Record; + cacheCounts?: Record; + jiraCounts?: Record; +} + +interface ObjectTypeWithLinks extends ObjectTypeDefinition { + enabled: boolean; // Whether this object type is enabled for syncing + incomingLinks: Array<{ + fromType: string; + fromTypeName: string; + attributeName: string; + isMultiple: boolean; + }>; + outgoingLinks: Array<{ + toType: string; + toTypeName: string; + attributeName: string; + isMultiple: boolean; + }>; +} + +class SchemaCacheService { + private cache: SchemaResponse | null = null; + private cacheTimestamp: number = 0; + private readonly CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + private db = getDatabaseAdapter(); // Use shared database adapter singleton + + /** + * Get schema from cache or fetch from database + */ + async getSchema(): Promise { + // Check cache validity + const now = Date.now(); + if (this.cache && (now - this.cacheTimestamp) < this.CACHE_TTL_MS) { + logger.debug('SchemaCache: Returning cached schema'); + return this.cache; + } + + // Cache expired or doesn't exist - fetch from database + logger.debug('SchemaCache: Cache expired or missing, fetching from database'); + const schema = await this.fetchFromDatabase(); + + // Update cache + this.cache = schema; + this.cacheTimestamp = now; + + return schema; + } + + /** + * Invalidate cache (force refresh on next request) + */ + invalidate(): void { + logger.debug('SchemaCache: Invalidating cache'); + this.cache = null; + this.cacheTimestamp = 0; + } + + /** + * Fetch schema from database and build response + * Returns ALL object types (enabled and disabled) with their sync status + */ + private async fetchFromDatabase(): Promise { + // Schema discovery must be manually triggered via API endpoints + // No automatic discovery on first run + + // Fetch ALL object types (enabled and disabled) with their schema info + const objectTypeRows = await this.db.query<{ + id: number; + schema_id: number; + jira_type_id: number; + type_name: string; + display_name: string; + description: string | null; + sync_priority: number; + object_count: number; + enabled: boolean | number; + }>( + `SELECT ot.id, ot.schema_id, ot.jira_type_id, ot.type_name, ot.display_name, ot.description, ot.sync_priority, ot.object_count, ot.enabled + FROM object_types ot + ORDER BY ot.sync_priority, ot.type_name` + ); + + if (objectTypeRows.length === 0) { + // No types found, return empty schema + return { + metadata: { + generatedAt: new Date().toISOString(), + objectTypeCount: 0, + totalAttributes: 0, + }, + objectTypes: {}, + }; + } + + // Fetch attributes for ALL object types using JOIN + const attributeRows = await this.db.query<{ + id: number; + jira_attr_id: number; + object_type_name: string; + attr_name: string; + field_name: string; + attr_type: string; + is_multiple: boolean | number; + is_editable: boolean | number; + is_required: boolean | number; + is_system: boolean | number; + reference_type_name: string | null; + description: string | null; + position: number | null; + schema_id: number; + type_name: string; + }>( + `SELECT a.*, ot.schema_id, ot.type_name + FROM attributes a + INNER JOIN object_types ot ON a.object_type_name = ot.type_name + ORDER BY ot.type_name, COALESCE(a.position, 0), a.jira_attr_id` + ); + + logger.debug(`SchemaCache: Found ${objectTypeRows.length} object types (enabled and disabled) and ${attributeRows.length} attributes`); + + // Build object types with attributes + // Use type_name as key (even if same type exists in multiple schemas, we'll show the first enabled one) + // In practice, if same type_name exists in multiple schemas, attributes should be the same + const objectTypesWithLinks: Record = {}; + + for (const typeRow of objectTypeRows) { + const typeName = typeRow.type_name; + + // Skip if we already have this type_name (first enabled one wins) + if (objectTypesWithLinks[typeName]) { + logger.debug(`SchemaCache: Skipping duplicate type_name ${typeName} from schema ${typeRow.schema_id}`); + continue; + } + + // Match attributes by both schema_id and type_name to ensure correct mapping + const matchingAttributes = attributeRows.filter(a => a.schema_id === typeRow.schema_id && a.type_name === typeName); + logger.debug(`SchemaCache: Found ${matchingAttributes.length} attributes for ${typeName} (schema_id: ${typeRow.schema_id})`); + + const attributes = matchingAttributes.map(attrRow => { + // Convert boolean/number for SQLite compatibility + const isMultiple = typeof attrRow.is_multiple === 'boolean' ? attrRow.is_multiple : attrRow.is_multiple === 1; + const isEditable = typeof attrRow.is_editable === 'boolean' ? attrRow.is_editable : attrRow.is_editable === 1; + const isRequired = typeof attrRow.is_required === 'boolean' ? attrRow.is_required : attrRow.is_required === 1; + const isSystem = typeof attrRow.is_system === 'boolean' ? attrRow.is_system : attrRow.is_system === 1; + + return { + jiraId: attrRow.jira_attr_id, + name: attrRow.attr_name, + fieldName: attrRow.field_name, + type: attrRow.attr_type as AttributeDefinition['type'], + isMultiple, + isEditable, + isRequired, + isSystem, + referenceTypeName: attrRow.reference_type_name || undefined, + description: attrRow.description || undefined, + position: attrRow.position ?? 0, + } as AttributeDefinition; + }); + + // Convert enabled boolean/number to boolean + const isEnabled = typeof typeRow.enabled === 'boolean' ? typeRow.enabled : typeRow.enabled === 1; + + objectTypesWithLinks[typeName] = { + jiraTypeId: typeRow.jira_type_id, + name: typeRow.display_name, + typeName: typeName, + syncPriority: typeRow.sync_priority, + objectCount: typeRow.object_count, + enabled: isEnabled, + attributes, + incomingLinks: [], + outgoingLinks: [], + }; + } + + // Build link relationships + for (const [typeName, typeDef] of Object.entries(objectTypesWithLinks)) { + for (const attr of typeDef.attributes) { + if (attr.type === 'reference' && attr.referenceTypeName) { + // Add outgoing link from this type + typeDef.outgoingLinks.push({ + toType: attr.referenceTypeName, + toTypeName: objectTypesWithLinks[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, + }); + } + } + } + } + + // Get cache counts (objectsByType) if available + let cacheCounts: Record | undefined; + try { + const { dataService } = await import('./dataService.js'); + const cacheStatus = await dataService.getCacheStatus(); + cacheCounts = cacheStatus.objectsByType; + } catch (err) { + logger.debug('SchemaCache: Could not fetch cache counts', err); + // Continue without cache counts - not critical + } + + // Calculate metadata (include enabled count) + const totalAttributes = Object.values(objectTypesWithLinks).reduce( + (sum, t) => sum + t.attributes.length, + 0 + ); + const enabledCount = Object.values(objectTypesWithLinks).filter(t => t.enabled).length; + + const response: SchemaResponse = { + metadata: { + generatedAt: new Date().toISOString(), + objectTypeCount: objectTypeRows.length, + totalAttributes, + enabledObjectTypeCount: enabledCount, + }, + objectTypes: objectTypesWithLinks, + cacheCounts, + }; + + return response; + } +} + +// Export singleton instance +export const schemaCacheService = new SchemaCacheService(); diff --git a/backend/src/services/schemaConfigurationService.ts b/backend/src/services/schemaConfigurationService.ts new file mode 100644 index 0000000..f41a9bb --- /dev/null +++ b/backend/src/services/schemaConfigurationService.ts @@ -0,0 +1,468 @@ +/** + * Schema Configuration Service + * + * Manages schema and object type configuration for syncing. + * Discovers schemas and object types from Jira Assets API and allows + * enabling/disabling specific object types for synchronization. + */ + +import { logger } from './logger.js'; +import { normalizedCacheStore } from './normalizedCacheStore.js'; +import { config } from '../config/env.js'; +import { toPascalCase } from './schemaUtils.js'; + +export interface JiraSchema { + id: number; + name: string; + description?: string; + objectTypeCount?: number; +} + +export interface JiraObjectType { + id: number; + name: string; + description?: string; + objectCount?: number; + objectSchemaId: number; + parentObjectTypeId?: number; + inherited?: boolean; + abstractObjectType?: boolean; +} + +export interface ConfiguredObjectType { + id: string; // "schemaId:objectTypeId" + schemaId: string; + schemaName: string; + objectTypeId: number; + objectTypeName: string; + displayName: string; + description: string | null; + objectCount: number; + enabled: boolean; + discoveredAt: string; + updatedAt: string; +} + +class SchemaConfigurationService { + constructor() { + // Configuration service - no API calls needed, uses database only + } + + /** + * NOTE: Schema discovery is now handled by SchemaSyncService. + * This service only manages configuration (enabling/disabling object types). + * Use schemaSyncService.syncAll() to discover and sync schemas, object types, and attributes. + */ + + /** + * Get all configured object types grouped by schema + */ + async getConfiguredObjectTypes(): Promise> { + const db = (normalizedCacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + await db.ensureInitialized?.(); + + const rows = await db.query<{ + id: number; + schema_id: number; + jira_schema_id: string; + schema_name: string; + jira_type_id: number; + type_name: string; + display_name: string; + description: string | null; + object_count: number; + enabled: boolean | number; + discovered_at: string; + updated_at: string; + }>(` + SELECT + ot.id, + ot.schema_id, + s.jira_schema_id, + s.name as schema_name, + ot.jira_type_id, + ot.type_name, + ot.display_name, + ot.description, + ot.object_count, + ot.enabled, + ot.discovered_at, + ot.updated_at + FROM object_types ot + JOIN schemas s ON ot.schema_id = s.id + ORDER BY s.name ASC, ot.display_name ASC + `); + + // Group by schema + const schemaMap = new Map(); + + for (const row of rows) { + const objectType: ConfiguredObjectType = { + id: `${row.jira_schema_id}:${row.jira_type_id}`, // Keep same format for compatibility + schemaId: row.jira_schema_id, + schemaName: row.schema_name, + objectTypeId: row.jira_type_id, + objectTypeName: row.type_name, + displayName: row.display_name, + description: row.description, + objectCount: row.object_count, + enabled: typeof row.enabled === 'boolean' ? row.enabled : row.enabled === 1, + discoveredAt: row.discovered_at, + updatedAt: row.updated_at, + }; + + if (!schemaMap.has(row.jira_schema_id)) { + schemaMap.set(row.jira_schema_id, []); + } + schemaMap.get(row.jira_schema_id)!.push(objectType); + } + + // Convert to array + return Array.from(schemaMap.entries()).map(([schemaId, objectTypes]) => { + const firstType = objectTypes[0]; + return { + schemaId, + schemaName: firstType.schemaName, + objectTypes, + }; + }); + } + + /** + * Set enabled status for an object type + * id format: "schemaId:objectTypeId" (e.g., "6:123") + */ + async setObjectTypeEnabled(id: string, enabled: boolean): Promise { + const db = (normalizedCacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + await db.ensureInitialized?.(); + + // Parse id: "schemaId:objectTypeId" + const [schemaIdStr, objectTypeIdStr] = id.split(':'); + if (!schemaIdStr || !objectTypeIdStr) { + throw new Error(`Invalid object type id format: ${id}. Expected format: "schemaId:objectTypeId"`); + } + + const objectTypeId = parseInt(objectTypeIdStr, 10); + if (isNaN(objectTypeId)) { + throw new Error(`Invalid object type id: ${objectTypeIdStr}`); + } + + // Get schema_id (FK) from schemas table + const schemaRow = await db.queryOne<{ id: number }>( + `SELECT id FROM schemas WHERE jira_schema_id = ?`, + [schemaIdStr] + ); + + if (!schemaRow) { + throw new Error(`Schema ${schemaIdStr} not found`); + } + + // Check if type_name is missing and try to fix it if enabling + const currentType = await db.queryOne<{ type_name: string | null; display_name: string }>( + `SELECT type_name, display_name FROM object_types WHERE schema_id = ? AND jira_type_id = ?`, + [schemaRow.id, objectTypeId] + ); + + let typeNameToSet = currentType?.type_name; + const needsTypeNameFix = enabled && (!typeNameToSet || typeNameToSet.trim() === ''); + + if (needsTypeNameFix && currentType?.display_name) { + // Try to generate type_name from display_name (PascalCase) + const { toPascalCase } = await import('./schemaUtils.js'); + typeNameToSet = toPascalCase(currentType.display_name); + logger.warn(`SchemaConfiguration: Type ${id} has missing type_name. Auto-generating "${typeNameToSet}" from display_name "${currentType.display_name}"`); + } + + const now = new Date().toISOString(); + + if (db.isPostgres) { + if (needsTypeNameFix && typeNameToSet) { + await db.execute(` + UPDATE object_types + SET enabled = ?, type_name = ?, updated_at = ? + WHERE schema_id = ? AND jira_type_id = ? + `, [enabled, typeNameToSet, now, schemaRow.id, objectTypeId]); + logger.info(`SchemaConfiguration: Set object type ${id} enabled=${enabled} and fixed missing type_name to "${typeNameToSet}"`); + } else { + await db.execute(` + UPDATE object_types + SET enabled = ?, updated_at = ? + WHERE schema_id = ? AND jira_type_id = ? + `, [enabled, now, schemaRow.id, objectTypeId]); + logger.info(`SchemaConfiguration: Set object type ${id} enabled=${enabled}`); + } + } else { + if (needsTypeNameFix && typeNameToSet) { + await db.execute(` + UPDATE object_types + SET enabled = ?, type_name = ?, updated_at = ? + WHERE schema_id = ? AND jira_type_id = ? + `, [enabled ? 1 : 0, typeNameToSet, now, schemaRow.id, objectTypeId]); + logger.info(`SchemaConfiguration: Set object type ${id} enabled=${enabled} and fixed missing type_name to "${typeNameToSet}"`); + } else { + await db.execute(` + UPDATE object_types + SET enabled = ?, updated_at = ? + WHERE schema_id = ? AND jira_type_id = ? + `, [enabled ? 1 : 0, now, schemaRow.id, objectTypeId]); + logger.info(`SchemaConfiguration: Set object type ${id} enabled=${enabled}`); + } + } + } + + /** + * Bulk update enabled status for multiple object types + */ + async bulkSetObjectTypesEnabled(updates: Array<{ id: string; enabled: boolean }>): Promise { + const db = (normalizedCacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + await db.ensureInitialized?.(); + + const now = new Date().toISOString(); + + await db.transaction(async (txDb) => { + for (const update of updates) { + // Parse id: "schemaId:objectTypeId" + const [schemaIdStr, objectTypeIdStr] = update.id.split(':'); + if (!schemaIdStr || !objectTypeIdStr) { + logger.warn(`SchemaConfiguration: Invalid object type id format: ${update.id}`); + continue; + } + + const objectTypeId = parseInt(objectTypeIdStr, 10); + if (isNaN(objectTypeId)) { + logger.warn(`SchemaConfiguration: Invalid object type id: ${objectTypeIdStr}`); + continue; + } + + // Get schema_id (FK) from schemas table + const schemaRow = await txDb.queryOne<{ id: number }>( + `SELECT id FROM schemas WHERE jira_schema_id = ?`, + [schemaIdStr] + ); + + if (!schemaRow) { + logger.warn(`SchemaConfiguration: Schema ${schemaIdStr} not found`); + continue; + } + + // Check if type_name is missing and try to fix it if enabling + const currentType = await txDb.queryOne<{ type_name: string | null; display_name: string }>( + `SELECT type_name, display_name FROM object_types WHERE schema_id = ? AND jira_type_id = ?`, + [schemaRow.id, objectTypeId] + ); + + let typeNameToSet = currentType?.type_name; + const needsTypeNameFix = update.enabled && (!typeNameToSet || typeNameToSet.trim() === ''); + + if (needsTypeNameFix && currentType?.display_name) { + // Try to generate type_name from display_name (PascalCase) + const { toPascalCase } = await import('./schemaUtils.js'); + typeNameToSet = toPascalCase(currentType.display_name); + logger.warn(`SchemaConfiguration: Type ${update.id} has missing type_name. Auto-generating "${typeNameToSet}" from display_name "${currentType.display_name}"`); + } + + if (txDb.isPostgres) { + if (needsTypeNameFix && typeNameToSet) { + await txDb.execute(` + UPDATE object_types + SET enabled = ?, type_name = ?, updated_at = ? + WHERE schema_id = ? AND jira_type_id = ? + `, [update.enabled, typeNameToSet, now, schemaRow.id, objectTypeId]); + } else { + await txDb.execute(` + UPDATE object_types + SET enabled = ?, updated_at = ? + WHERE schema_id = ? AND jira_type_id = ? + `, [update.enabled, now, schemaRow.id, objectTypeId]); + } + } else { + if (needsTypeNameFix && typeNameToSet) { + await txDb.execute(` + UPDATE object_types + SET enabled = ?, type_name = ?, updated_at = ? + WHERE schema_id = ? AND jira_type_id = ? + `, [update.enabled ? 1 : 0, typeNameToSet, now, schemaRow.id, objectTypeId]); + } else { + await txDb.execute(` + UPDATE object_types + SET enabled = ?, updated_at = ? + WHERE schema_id = ? AND jira_type_id = ? + `, [update.enabled ? 1 : 0, now, schemaRow.id, objectTypeId]); + } + } + } + }); + + logger.info(`SchemaConfiguration: Bulk updated ${updates.length} object types`); + } + + /** + * Get enabled object types (for sync engine) + */ + async getEnabledObjectTypes(): Promise> { + const db = (normalizedCacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + await db.ensureInitialized?.(); + + // Use parameterized query to avoid boolean/integer comparison issues + const rows = await db.query<{ + jira_schema_id: string; + jira_type_id: number; + type_name: string; + display_name: string; + }>( + `SELECT s.jira_schema_id, ot.jira_type_id, ot.type_name, ot.display_name + FROM object_types ot + JOIN schemas s ON ot.schema_id = s.id + WHERE ot.enabled = ?`, + [db.isPostgres ? true : 1] + ); + + return rows.map(row => ({ + schemaId: row.jira_schema_id, + objectTypeId: row.jira_type_id, + objectTypeName: row.type_name, + displayName: row.display_name, + })); + } + + /** + * Check if configuration is complete (at least one object type enabled) + */ + async isConfigurationComplete(): Promise { + const enabledTypes = await this.getEnabledObjectTypes(); + return enabledTypes.length > 0; + } + + /** + * Get configuration statistics + */ + async getConfigurationStats(): Promise<{ + totalSchemas: number; + totalObjectTypes: number; + enabledObjectTypes: number; + disabledObjectTypes: number; + isConfigured: boolean; + }> { + const db = (normalizedCacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + await db.ensureInitialized?.(); + + const totalRow = await db.queryOne<{ count: number }>(` + SELECT COUNT(*) as count FROM object_types + `); + + // Use parameterized query to avoid boolean/integer comparison issues + const enabledRow = await db.queryOne<{ count: number }>( + `SELECT COUNT(*) as count FROM object_types WHERE enabled = ?`, + [db.isPostgres ? true : 1] + ); + + const schemaRow = await db.queryOne<{ count: number }>(` + SELECT COUNT(*) as count FROM schemas + `); + + const total = totalRow?.count || 0; + const enabled = enabledRow?.count || 0; + const schemas = schemaRow?.count || 0; + + return { + totalSchemas: schemas, + totalObjectTypes: total, + enabledObjectTypes: enabled, + disabledObjectTypes: total - enabled, + isConfigured: enabled > 0, + }; + } + + /** + * Get all schemas with their search enabled status + */ + async getSchemas(): Promise> { + const db = (normalizedCacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + await db.ensureInitialized?.(); + + const rows = await db.query<{ + jira_schema_id: string; + name: string; + search_enabled: boolean | number; + }>(` + SELECT jira_schema_id, name, search_enabled + FROM schemas + ORDER BY name ASC + `); + + return rows.map(row => ({ + schemaId: row.jira_schema_id, + schemaName: row.name, + searchEnabled: typeof row.search_enabled === 'boolean' ? row.search_enabled : row.search_enabled === 1, + })); + } + + /** + * Set search enabled status for a schema + */ + async setSchemaSearchEnabled(schemaId: string, searchEnabled: boolean): Promise { + const db = (normalizedCacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + await db.ensureInitialized?.(); + + const now = new Date().toISOString(); + + if (db.isPostgres) { + await db.execute(` + UPDATE schemas + SET search_enabled = ?, updated_at = ? + WHERE jira_schema_id = ? + `, [searchEnabled, now, schemaId]); + } else { + await db.execute(` + UPDATE schemas + SET search_enabled = ?, updated_at = ? + WHERE jira_schema_id = ? + `, [searchEnabled ? 1 : 0, now, schemaId]); + } + + logger.info(`SchemaConfiguration: Set schema ${schemaId} search_enabled=${searchEnabled}`); + } +} + +export const schemaConfigurationService = new SchemaConfigurationService(); diff --git a/backend/src/services/schemaDiscoveryService.ts b/backend/src/services/schemaDiscoveryService.ts new file mode 100644 index 0000000..6c4b1ce --- /dev/null +++ b/backend/src/services/schemaDiscoveryService.ts @@ -0,0 +1,182 @@ +/** + * Schema Discovery Service + * + * Provides access to discovered schema data from the database. + * Schema synchronization is handled by SchemaSyncService. + * This service provides read-only access to the discovered schema. + */ + +import { logger } from './logger.js'; +import { getDatabaseAdapter } from './database/singleton.js'; +import type { DatabaseAdapter } from './database/interface.js'; +import { schemaSyncService } from './SchemaSyncService.js'; +import type { ObjectTypeDefinition, AttributeDefinition } from '../generated/jira-schema.js'; + +// Jira API Types (kept for reference, but not used in this service anymore) + +class SchemaDiscoveryService { + private db: DatabaseAdapter; + private isPostgres: boolean; + + constructor() { + // Use shared database adapter singleton + this.db = getDatabaseAdapter(); + // Determine if PostgreSQL based on adapter type + this.isPostgres = (this.db.isPostgres === true); + } + + /** + * Discover schema from Jira Assets API and populate database + * Delegates to SchemaSyncService for the actual synchronization + */ + async discoverAndStoreSchema(force: boolean = false): Promise { + logger.info('SchemaDiscovery: Delegating to SchemaSyncService for schema synchronization...'); + await schemaSyncService.syncAll(); + } + + /** + * Get attribute definition from database + */ + async getAttribute(typeName: string, fieldName: string): Promise { + + const row = await this.db.queryOne<{ + jira_attr_id: number; + attr_name: string; + field_name: string; + attr_type: string; + is_multiple: boolean | number; + is_editable: boolean | number; + is_required: boolean | number; + is_system: boolean | number; + reference_type_name: string | null; + description: string | null; + }>(` + SELECT * FROM attributes + WHERE object_type_name = ? AND field_name = ? + `, [typeName, fieldName]); + + if (!row) return null; + + // Convert boolean/number for SQLite compatibility + const isMultiple = typeof row.is_multiple === 'boolean' ? row.is_multiple : row.is_multiple === 1; + const isEditable = typeof row.is_editable === 'boolean' ? row.is_editable : row.is_editable === 1; + const isRequired = typeof row.is_required === 'boolean' ? row.is_required : row.is_required === 1; + const isSystem = typeof row.is_system === 'boolean' ? row.is_system : row.is_system === 1; + + return { + jiraId: row.jira_attr_id, + name: row.attr_name, + fieldName: row.field_name, + type: row.attr_type as AttributeDefinition['type'], + isMultiple, + isEditable, + isRequired, + isSystem, + referenceTypeName: row.reference_type_name || undefined, + description: row.description || undefined, + }; + } + + /** + * Get all attributes for a type + */ + async getAttributesForType(typeName: string): Promise { + + const rows = await this.db.query<{ + jira_attr_id: number; + attr_name: string; + field_name: string; + attr_type: string; + is_multiple: boolean | number; + is_editable: boolean | number; + is_required: boolean | number; + is_system: boolean | number; + reference_type_name: string | null; + description: string | null; + position: number | null; + }>(` + SELECT * FROM attributes + WHERE object_type_name = ? + ORDER BY COALESCE(position, 0), jira_attr_id + `, [typeName]); + + return rows.map(row => { + // Convert boolean/number for SQLite compatibility + const isMultiple = typeof row.is_multiple === 'boolean' ? row.is_multiple : row.is_multiple === 1; + const isEditable = typeof row.is_editable === 'boolean' ? row.is_editable : row.is_editable === 1; + const isRequired = typeof row.is_required === 'boolean' ? row.is_required : row.is_required === 1; + const isSystem = typeof row.is_system === 'boolean' ? row.is_system : row.is_system === 1; + + return { + jiraId: row.jira_attr_id, + name: row.attr_name, + fieldName: row.field_name, + type: row.attr_type as AttributeDefinition['type'], + isMultiple, + isEditable, + isRequired, + isSystem, + referenceTypeName: row.reference_type_name || undefined, + description: row.description || undefined, + position: row.position ?? 0, + }; + }); + } + + /** + * Get object type definition from database + */ + async getObjectType(typeName: string): Promise { + + const row = await this.db.queryOne<{ + jira_type_id: number; + type_name: string; + display_name: string; + description: string | null; + sync_priority: number; + object_count: number; + }>(` + SELECT * FROM object_types + WHERE type_name = ? + `, [typeName]); + + if (!row) return null; + + const attributes = await this.getAttributesForType(typeName); + + return { + jiraTypeId: row.jira_type_id, + name: row.display_name, + typeName: row.type_name, + syncPriority: row.sync_priority, + objectCount: row.object_count, + attributes, + }; + } + + /** + * Get attribute ID by type and field name or attribute name + * Supports both fieldName (camelCase) and name (display name) for flexibility + */ + async getAttributeId(typeName: string, fieldNameOrName: string): Promise { + + // Try field_name first (camelCase) + let row = await this.db.queryOne<{ id: number }>(` + SELECT id FROM attributes + WHERE object_type_name = ? AND field_name = ? + `, [typeName, fieldNameOrName]); + + // If not found, try attr_name (display name) + if (!row) { + row = await this.db.queryOne<{ id: number }>(` + SELECT id FROM attributes + WHERE object_type_name = ? AND attr_name = ? + `, [typeName, fieldNameOrName]); + } + + return row?.id || null; + } +} + +// Export singleton instance +export const schemaDiscoveryService = new SchemaDiscoveryService(); diff --git a/backend/src/services/schemaMappingService.ts b/backend/src/services/schemaMappingService.ts new file mode 100644 index 0000000..50c5f9f --- /dev/null +++ b/backend/src/services/schemaMappingService.ts @@ -0,0 +1,380 @@ +/** + * Schema Mapping Service + * + * Manages mappings between object types and their Jira Assets schema IDs. + * Allows different object types to exist in different schemas. + */ + +import { logger } from './logger.js'; +import { normalizedCacheStore } from './normalizedCacheStore.js'; +import { config } from '../config/env.js'; +import type { CMDBObjectTypeName } from '../generated/jira-types.js'; + +export interface SchemaMapping { + objectTypeName: string; + schemaId: string; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +class SchemaMappingService { + private cache: Map = new Map(); // objectTypeName -> schemaId + private cacheInitialized: boolean = false; + + /** + * Get schema ID for an object type + * Returns the configured schema ID or the default from config + */ + async getSchemaId(objectTypeName: CMDBObjectTypeName | string): Promise { + await this.ensureCacheInitialized(); + + // Check cache first + if (this.cache.has(objectTypeName)) { + return this.cache.get(objectTypeName)!; + } + + // Try to get schema ID from database (from enabled object types) + try { + const { schemaConfigurationService } = await import('./schemaConfigurationService.js'); + const enabledTypes = await schemaConfigurationService.getEnabledObjectTypes(); + const type = enabledTypes.find(et => et.objectTypeName === objectTypeName); + if (type) { + return type.schemaId; + } + } catch (error) { + logger.warn(`SchemaMapping: Failed to get schema ID from database for ${objectTypeName}`, error); + } + + // Return empty string if not found (no default) + return ''; + } + + /** + * Get all schema mappings + */ + async getAllMappings(): Promise { + const db = (normalizedCacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + await db.ensureInitialized?.(); + + const rows = await db.query<{ + object_type_name: string; + schema_id: string; + enabled: boolean | number; + created_at: string; + updated_at: string; + }>(` + SELECT object_type_name, schema_id, enabled, created_at, updated_at + FROM schema_mappings + ORDER BY object_type_name + `); + + return rows.map(row => ({ + objectTypeName: row.object_type_name, + schemaId: row.schema_id, + enabled: typeof row.enabled === 'boolean' ? row.enabled : row.enabled === 1, + createdAt: row.created_at, + updatedAt: row.updated_at, + })); + } + + /** + * Set schema mapping for an object type + */ + async setMapping(objectTypeName: string, schemaId: string, enabled: boolean = true): Promise { + const db = (normalizedCacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + await db.ensureInitialized?.(); + + const now = new Date().toISOString(); + + if (db.isPostgres) { + await db.execute(` + INSERT INTO schema_mappings (object_type_name, schema_id, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(object_type_name) DO UPDATE SET + schema_id = excluded.schema_id, + enabled = excluded.enabled, + updated_at = excluded.updated_at + `, [objectTypeName, schemaId, enabled, now, now]); + } else { + await db.execute(` + INSERT INTO schema_mappings (object_type_name, schema_id, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(object_type_name) DO UPDATE SET + schema_id = excluded.schema_id, + enabled = excluded.enabled, + updated_at = excluded.updated_at + `, [objectTypeName, schemaId, enabled ? 1 : 0, now, now]); + } + + // Update cache + if (enabled) { + this.cache.set(objectTypeName, schemaId); + } else { + this.cache.delete(objectTypeName); + } + + logger.info(`SchemaMappingService: Set mapping for ${objectTypeName} -> schema ${schemaId} (enabled: ${enabled})`); + } + + /** + * Delete schema mapping for an object type (will use default schema) + */ + async deleteMapping(objectTypeName: string): Promise { + const db = (normalizedCacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + await db.ensureInitialized?.(); + + await db.execute(` + DELETE FROM schema_mappings WHERE object_type_name = ? + `, [objectTypeName]); + + // Remove from cache + this.cache.delete(objectTypeName); + + logger.info(`SchemaMappingService: Deleted mapping for ${objectTypeName}`); + } + + /** + * Check if an object type should be synced (has enabled mapping or uses default schema) + */ + async isTypeEnabled(objectTypeName: string): Promise { + await this.ensureCacheInitialized(); + + // If there's a mapping, check if it's enabled + if (this.cache.has(objectTypeName)) { + // Check if it's actually enabled in the database + const db = (normalizedCacheStore as any).db; + if (db) { + await db.ensureInitialized?.(); + const row = await db.queryOne<{ enabled: boolean | number }>(` + SELECT enabled FROM schema_mappings WHERE object_type_name = ? + `, [objectTypeName]); + + if (row) { + return typeof row.enabled === 'boolean' ? row.enabled : row.enabled === 1; + } + } + } + + // If no mapping exists, check if object type is enabled in database + try { + const { schemaConfigurationService } = await import('./schemaConfigurationService.js'); + const enabledTypes = await schemaConfigurationService.getEnabledObjectTypes(); + return enabledTypes.some(et => et.objectTypeName === objectTypeName); + } catch (error) { + logger.warn(`SchemaMapping: Failed to check if ${objectTypeName} is enabled`, error); + return false; + } + } + + /** + * Initialize cache from database + */ + private async ensureCacheInitialized(): Promise { + if (this.cacheInitialized) return; + + try { + const db = (normalizedCacheStore as any).db; + if (!db) { + this.cacheInitialized = true; + return; + } + + await db.ensureInitialized?.(); + + // Use parameterized query to avoid boolean/integer comparison issues + const rows = await db.query<{ + object_type_name: string; + schema_id: string; + enabled: boolean | number; + }>( + `SELECT object_type_name, schema_id, enabled + FROM schema_mappings + WHERE enabled = ?`, + [db.isPostgres ? true : 1] + ); + + this.cache.clear(); + for (const row of rows) { + const enabled = typeof row.enabled === 'boolean' ? row.enabled : row.enabled === 1; + if (enabled) { + this.cache.set(row.object_type_name, row.schema_id); + } + } + + this.cacheInitialized = true; + logger.debug(`SchemaMappingService: Initialized cache with ${this.cache.size} mappings`); + } catch (error) { + logger.warn('SchemaMappingService: Failed to initialize cache, using defaults', error); + this.cacheInitialized = true; // Mark as initialized to prevent retry loops + } + } + + /** + * Get all object types with their sync configuration + */ + async getAllObjectTypesWithConfig(): Promise> { + const db = (normalizedCacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + await db.ensureInitialized?.(); + + try { + // Get all object types with their mappings + const rows = await db.query<{ + type_name: string; + display_name: string; + description: string | null; + object_count: number; + sync_priority: number; + schema_id: string | null; + enabled: boolean | number | null; + }>(` + SELECT + ot.type_name, + ot.display_name, + ot.description, + ot.object_count, + ot.sync_priority, + sm.schema_id, + sm.enabled + FROM object_types ot + LEFT JOIN schema_mappings sm ON ot.type_name = sm.object_type_name + ORDER BY ot.sync_priority ASC, ot.display_name ASC + `); + + logger.debug(`SchemaMappingService: Found ${rows.length} object types in database`); + + // Get first available schema ID from database + let defaultSchemaId: string | null = null; + try { + const { normalizedCacheStore } = await import('./normalizedCacheStore.js'); + const db = (normalizedCacheStore as any).db; + if (db) { + await db.ensureInitialized?.(); + const schemaRow = await db.queryOne<{ jira_schema_id: string }>( + `SELECT jira_schema_id FROM schemas ORDER BY jira_schema_id LIMIT 1` + ); + defaultSchemaId = schemaRow?.jira_schema_id || null; + } + } catch (error) { + logger.warn('SchemaMapping: Failed to get default schema ID from database', error); + } + + return rows.map(row => ({ + typeName: row.type_name, + displayName: row.display_name, + description: row.description, + schemaId: row.schema_id || defaultSchemaId, + enabled: row.enabled === null + ? true // Default: enabled if no mapping exists + : (typeof row.enabled === 'boolean' ? row.enabled : row.enabled === 1), + objectCount: row.object_count, + syncPriority: row.sync_priority, + })); + } catch (error) { + logger.error('SchemaMappingService: Failed to get object types with config', error); + throw error; + } + } + + /** + * Enable or disable an object type for syncing + */ + async setTypeEnabled(objectTypeName: string, enabled: boolean): Promise { + const db = (normalizedCacheStore as any).db; + if (!db) { + throw new Error('Database not available'); + } + + await db.ensureInitialized?.(); + + // Check if mapping exists + const existing = await db.queryOne<{ schema_id: string }>(` + SELECT schema_id FROM schema_mappings WHERE object_type_name = ? + `, [objectTypeName]); + + // Get schema ID from existing mapping or from database + let schemaId = existing?.schema_id || ''; + if (!schemaId) { + // Try to get schema ID from database (from enabled object types) + try { + const { schemaConfigurationService } = await import('./schemaConfigurationService.js'); + const enabledTypes = await schemaConfigurationService.getEnabledObjectTypes(); + const type = enabledTypes.find(et => et.objectTypeName === objectTypeName); + if (type) { + schemaId = type.schemaId; + } + } catch (error) { + logger.warn(`SchemaMapping: Failed to get schema ID from database for ${objectTypeName}`, error); + } + } + + if (!schemaId) { + throw new Error(`No schema ID available for object type ${objectTypeName}. Please ensure the object type is discovered and configured.`); + } + + // Create or update mapping + const now = new Date().toISOString(); + + if (db.isPostgres) { + await db.execute(` + INSERT INTO schema_mappings (object_type_name, schema_id, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(object_type_name) DO UPDATE SET + enabled = excluded.enabled, + updated_at = excluded.updated_at + `, [objectTypeName, schemaId, enabled, now, now]); + } else { + await db.execute(` + INSERT INTO schema_mappings (object_type_name, schema_id, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(object_type_name) DO UPDATE SET + enabled = excluded.enabled, + updated_at = excluded.updated_at + `, [objectTypeName, schemaId, enabled ? 1 : 0, now, now]); + } + + // Update cache + if (enabled) { + this.cache.set(objectTypeName, schemaId); + } else { + this.cache.delete(objectTypeName); + } + + this.clearCache(); + logger.info(`SchemaMappingService: Set ${objectTypeName} enabled=${enabled}`); + } + + /** + * Clear cache (useful after updates) + */ + clearCache(): void { + this.cache.clear(); + this.cacheInitialized = false; + } +} + +export const schemaMappingService = new SchemaMappingService(); diff --git a/backend/src/services/schemaUtils.ts b/backend/src/services/schemaUtils.ts new file mode 100644 index 0000000..9e89ef8 --- /dev/null +++ b/backend/src/services/schemaUtils.ts @@ -0,0 +1,149 @@ +/** + * Schema Utility Functions + * + * Helper functions for schema discovery and type conversion + */ + +// Jira attribute type mappings (based on Jira Insight/Assets API) +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$/, +]; + +/** + * Convert a string to camelCase while preserving existing casing patterns + * E.g., "Application Function" -> "applicationFunction" + * "ICT Governance Model" -> "ictGovernanceModel" + * "ApplicationFunction" -> "applicationFunction" + */ +export 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" + */ +export 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(''); +} + +/** + * Map Jira attribute type ID to our type system + */ +export function mapJiraType(typeId: number): 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown' { + return JIRA_TYPE_MAP[typeId] || 'unknown'; +} + +/** + * Determine sync priority for an object type + */ +export 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; +} diff --git a/backend/src/services/syncEngine.ts b/backend/src/services/syncEngine.ts index 6ce33d8..77fcb5b 100644 --- a/backend/src/services/syncEngine.ts +++ b/backend/src/services/syncEngine.ts @@ -8,10 +8,12 @@ */ import { logger } from './logger.js'; -import { cacheStore } from './cacheStore.js'; +import { normalizedCacheStore as cacheStore } from './normalizedCacheStore.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'; +import { schemaDiscoveryService } from './schemaDiscoveryService.js'; +import type { ObjectEntry } from '../domain/jiraAssetsPayload.js'; // ============================================================================= // Types @@ -61,6 +63,7 @@ class SyncEngine { private incrementalInterval: number; private batchSize: number; private lastIncrementalSync: Date | null = null; + private lastConfigCheck: number = 0; // Track last config check time to avoid log spam constructor() { this.incrementalInterval = parseInt( @@ -93,7 +96,26 @@ class SyncEngine { logger.info('SyncEngine: Sync uses service account token (JIRA_SERVICE_ACCOUNT_TOKEN) from .env'); this.isRunning = true; - // Sync can run automatically using service account token + // Check if configuration is complete before starting scheduler + const { schemaConfigurationService } = await import('./schemaConfigurationService.js'); + const isConfigured = await schemaConfigurationService.isConfigurationComplete(); + + // Start incremental sync scheduler if token is available AND configuration is complete + if (jiraAssetsClient.hasToken()) { + if (isConfigured) { + this.startIncrementalSyncScheduler(); + logger.info('SyncEngine: Incremental sync scheduler started (configuration complete)'); + } else { + logger.info('SyncEngine: Incremental sync scheduler NOT started - schema configuration not complete. Please configure object types in settings first.'); + // Start scheduler but it will check configuration on each run + // This allows scheduler to start automatically when configuration is completed later + this.startIncrementalSyncScheduler(); + logger.info('SyncEngine: Incremental sync scheduler started (will check configuration on each run)'); + } + } else { + logger.info('SyncEngine: Service account token not configured, incremental sync disabled'); + } + logger.info('SyncEngine: Initialized (using service account token for sync operations)'); } @@ -163,14 +185,42 @@ class SyncEngine { logger.info('SyncEngine: Starting full sync...'); try { - // Get object types sorted by sync priority - const objectTypes = getObjectTypesBySyncPriority(); + // Check if configuration is complete + const { schemaConfigurationService } = await import('./schemaConfigurationService.js'); + const isConfigured = await schemaConfigurationService.isConfigurationComplete(); + if (!isConfigured) { + throw new Error('Schema configuration not complete. Please configure at least one object type to be synced in the settings page.'); + } - for (const typeDef of objectTypes) { - const typeStat = await this.syncObjectType(typeDef.typeName as CMDBObjectTypeName); - stats.push(typeStat); - totalObjects += typeStat.objectsProcessed; - totalRelations += typeStat.relationsExtracted; + // Get enabled object types from configuration + logger.info('SyncEngine: Fetching enabled object types from configuration...'); + const enabledTypes = await schemaConfigurationService.getEnabledObjectTypes(); + logger.info(`SyncEngine: Found ${enabledTypes.length} enabled object types to sync`); + + if (enabledTypes.length === 0) { + throw new Error('No object types enabled for syncing. Please enable at least one object type in the settings page.'); + } + + // Schema discovery will happen automatically when needed (e.g., for relation extraction) + // It's no longer required upfront - the user has already configured which object types to sync + logger.info('SyncEngine: Starting object sync for configured object types...'); + + // Sync each enabled object type + for (const enabledType of enabledTypes) { + try { + const typeStat = await this.syncConfiguredObjectType(enabledType); + stats.push(typeStat); + totalObjects += typeStat.objectsProcessed; + totalRelations += typeStat.relationsExtracted; + } catch (error) { + logger.error(`SyncEngine: Failed to sync ${enabledType.displayName}`, error); + stats.push({ + objectType: enabledType.displayName, + objectsProcessed: 0, + relationsExtracted: 0, + duration: 0, + }); + } } // Update sync metadata @@ -205,81 +255,216 @@ class SyncEngine { } /** - * Sync a single object type + * Store an object and all its nested referenced objects recursively + * This method processes the entire object tree, storing all nested objects + * and extracting all relations, while preventing infinite loops with circular references. + * + * @param entry - The object entry to store (in ObjectEntry format from API) + * @param typeName - The type name of the object + * @param processedIds - Set of already processed object IDs (to prevent duplicates and circular refs) + * @returns Statistics about objects stored and relations extracted */ - private async syncObjectType(typeName: CMDBObjectTypeName): Promise { + private async storeObjectTree( + entry: ObjectEntry, + typeName: CMDBObjectTypeName, + processedIds: Set + ): Promise<{ objectsStored: number; relationsExtracted: number }> { + const entryId = String(entry.id); + + // Skip if already processed (handles circular references) + if (processedIds.has(entryId)) { + logger.debug(`SyncEngine: Skipping already processed object ${entry.objectKey || entryId} of type ${typeName}`); + return { objectsStored: 0, relationsExtracted: 0 }; + } + processedIds.add(entryId); + + let objectsStored = 0; + let relationsExtracted = 0; + + try { + logger.debug(`SyncEngine: [Recursive] Storing object tree for ${entry.objectKey || entryId} of type ${typeName} (depth: ${processedIds.size - 1})`); + + // 1. Adapt and parse the object + const adapted = jiraAssetsClient.adaptObjectEntryToJiraAssetsObject(entry); + if (!adapted) { + logger.warn(`SyncEngine: Failed to adapt object ${entry.objectKey || entryId}`); + return { objectsStored: 0, relationsExtracted: 0 }; + } + + const parsed = await jiraAssetsClient.parseObject(adapted); + if (!parsed) { + logger.warn(`SyncEngine: Failed to parse object ${entry.objectKey || entryId}`); + return { objectsStored: 0, relationsExtracted: 0 }; + } + + // 2. Store the object + await cacheStore.upsertObject(typeName, parsed); + objectsStored++; + logger.debug(`SyncEngine: Stored object ${parsed.objectKey || parsed.id} of type ${typeName}`); + + // 3. Schema discovery must be manually triggered via API endpoints + // No automatic discovery + + // 4. Extract and store relations for this object + await cacheStore.extractAndStoreRelations(typeName, parsed); + relationsExtracted++; + logger.debug(`SyncEngine: Extracted relations for object ${parsed.objectKey || parsed.id}`); + + // 5. Recursively process nested referenced objects + // Note: Lookup maps should already be initialized by getAllObjectsOfType + // Use a separate Set for extraction to avoid conflicts with storage tracking + const extractionProcessedIds = new Set(); + const nestedRefs = jiraAssetsClient.extractNestedReferencedObjects( + entry, + extractionProcessedIds, // Separate Set for extraction (prevents infinite loops in traversal) + 5, // max depth + 0 // current depth + ); + + if (nestedRefs.length > 0) { + logger.debug(`SyncEngine: [Recursive] Found ${nestedRefs.length} nested referenced objects for ${entry.objectKey || entryId}`); + + // Group by type for better logging + const refsByType = new Map(); + for (const ref of nestedRefs) { + refsByType.set(ref.typeName, (refsByType.get(ref.typeName) || 0) + 1); + } + const typeSummary = Array.from(refsByType.entries()) + .map(([type, count]) => `${count} ${type}`) + .join(', '); + logger.debug(`SyncEngine: [Recursive] Nested objects by type: ${typeSummary}`); + } + + // 6. Recursively store each nested object + for (const { entry: nestedEntry, typeName: nestedTypeName } of nestedRefs) { + logger.debug(`SyncEngine: [Recursive] Processing nested object ${nestedEntry.objectKey || nestedEntry.id} of type ${nestedTypeName}`); + const nestedResult = await this.storeObjectTree( + nestedEntry, + nestedTypeName as CMDBObjectTypeName, + processedIds + ); + objectsStored += nestedResult.objectsStored; + relationsExtracted += nestedResult.relationsExtracted; + } + + logger.debug(`SyncEngine: [Recursive] Completed storing object tree for ${entry.objectKey || entryId}: ${objectsStored} objects, ${relationsExtracted} relations`); + return { objectsStored, relationsExtracted }; + } catch (error) { + logger.error(`SyncEngine: Failed to store object tree for ${entry.objectKey || entryId}`, error); + return { objectsStored, relationsExtracted }; + } + } + + /** + * Sync a configured object type (from schema configuration) + */ + private async syncConfiguredObjectType(enabledType: { + schemaId: string; + objectTypeId: number; + objectTypeName: string; + displayName: string; + }): 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.info(`SyncEngine: Syncing ${enabledType.displayName} (${enabledType.objectTypeName}) from schema ${enabledType.schemaId}...`); - logger.debug(`SyncEngine: Syncing ${typeName}...`); + // Fetch all objects from Jira using the configured schema and object type + // This returns raw entries for recursive processing (includeAttributesDeep=2 provides nested data) + const { objects: jiraObjects, rawEntries } = await jiraAssetsClient.getAllObjectsOfType( + enabledType.displayName, // Use display name for Jira API + this.batchSize, + enabledType.schemaId + ); + logger.info(`SyncEngine: Fetched ${jiraObjects.length} ${enabledType.displayName} objects from Jira (schema: ${enabledType.schemaId})`); - // Fetch all objects from Jira - const jiraObjects = await jiraAssetsClient.getAllObjectsOfType(typeName, this.batchSize); - logger.info(`SyncEngine: Fetched ${jiraObjects.length} ${typeName} objects from Jira`); + // Schema discovery must be manually triggered via API endpoints + // No automatic discovery - // Parse and cache objects - const parsedObjects: CMDBObject[] = []; + // Use objectTypeName for cache storage (PascalCase) + const typeName = enabledType.objectTypeName as CMDBObjectTypeName; + + // Process each main object recursively using storeObjectTree + // This will store the object and all its nested referenced objects + const processedIds = new Set(); // Track processed objects to prevent duplicates and circular refs const failedObjects: Array<{ id: string; key: string; label: string; reason: string }> = []; - for (const jiraObj of jiraObjects) { - const parsed = jiraAssetsClient.parseObject(jiraObj); - if (parsed) { - parsedObjects.push(parsed); - } else { - // Track objects that failed to parse - failedObjects.push({ - id: jiraObj.id?.toString() || 'unknown', - key: jiraObj.objectKey || 'unknown', - label: jiraObj.label || 'unknown', - reason: 'parseObject returned null', - }); - logger.warn(`SyncEngine: Failed to parse ${typeName} object: ${jiraObj.objectKey || jiraObj.id} (${jiraObj.label || 'unknown label'})`); + if (rawEntries && rawEntries.length > 0) { + logger.info(`SyncEngine: Processing ${rawEntries.length} ${enabledType.displayName} objects recursively...`); + + for (const rawEntry of rawEntries) { + try { + const result = await this.storeObjectTree(rawEntry, typeName, processedIds); + objectsProcessed += result.objectsStored; + relationsExtracted += result.relationsExtracted; + } catch (error) { + const entryId = String(rawEntry.id); + failedObjects.push({ + id: entryId, + key: rawEntry.objectKey || 'unknown', + label: rawEntry.label || 'unknown', + reason: error instanceof Error ? error.message : 'Unknown error', + }); + logger.warn(`SyncEngine: Failed to store object tree for ${enabledType.displayName} object: ${rawEntry.objectKey || entryId} (${rawEntry.label || 'unknown label'})`, error); + } + } + } else { + // Fallback: if rawEntries not available, use adapted objects (less efficient, no recursion) + logger.warn(`SyncEngine: Raw entries not available, using fallback linear processing (no recursive nesting)`); + + const parsedObjects: CMDBObject[] = []; + for (const jiraObj of jiraObjects) { + const parsed = await jiraAssetsClient.parseObject(jiraObj); + if (parsed) { + parsedObjects.push(parsed); + } else { + failedObjects.push({ + id: jiraObj.id?.toString() || 'unknown', + key: jiraObj.objectKey || 'unknown', + label: jiraObj.label || 'unknown', + reason: 'parseObject returned null', + }); + logger.warn(`SyncEngine: Failed to parse ${enabledType.displayName} object: ${jiraObj.objectKey || jiraObj.id} (${jiraObj.label || 'unknown label'})`); + } + } + + if (parsedObjects.length > 0) { + await cacheStore.batchUpsertObjects(typeName, parsedObjects); + objectsProcessed = parsedObjects.length; + + // Extract relations + for (const obj of parsedObjects) { + await cacheStore.extractAndStoreRelations(typeName, obj); + relationsExtracted++; + } } } // Log parsing statistics if (failedObjects.length > 0) { - logger.warn(`SyncEngine: ${failedObjects.length} ${typeName} objects failed to parse:`, failedObjects.map(o => `${o.key} (${o.label})`).join(', ')); - } - - // Batch upsert to cache - if (parsedObjects.length > 0) { - await cacheStore.batchUpsertObjects(typeName, parsedObjects); - objectsProcessed = parsedObjects.length; - - // Extract relations - for (const obj of parsedObjects) { - await cacheStore.extractAndStoreRelations(typeName, obj); - relationsExtracted++; - } + logger.warn(`SyncEngine: ${failedObjects.length} ${enabledType.displayName} objects failed to process:`, failedObjects.map(o => `${o.key} (${o.label}): ${o.reason}`).join(', ')); } const duration = Date.now() - startTime; const skippedCount = jiraObjects.length - objectsProcessed; if (skippedCount > 0) { - logger.warn(`SyncEngine: Synced ${objectsProcessed}/${jiraObjects.length} ${typeName} objects in ${duration}ms (${skippedCount} skipped)`); + logger.warn(`SyncEngine: Synced ${objectsProcessed}/${jiraObjects.length} ${enabledType.displayName} objects in ${duration}ms (${skippedCount} skipped)`); } else { - logger.debug(`SyncEngine: Synced ${objectsProcessed} ${typeName} objects in ${duration}ms`); + logger.debug(`SyncEngine: Synced ${objectsProcessed} ${enabledType.displayName} objects in ${duration}ms`); } return { - objectType: typeName, + objectType: enabledType.displayName, objectsProcessed, relationsExtracted, duration, }; } catch (error) { - logger.error(`SyncEngine: Failed to sync ${typeName}`, error); + logger.error(`SyncEngine: Failed to sync ${enabledType.displayName}`, error); return { - objectType: typeName, + objectType: enabledType.displayName, objectsProcessed, relationsExtracted, duration: Date.now() - startTime, @@ -287,12 +472,27 @@ class SyncEngine { } } + /** + * Sync a single object type (legacy method, kept for backward compatibility) + */ + private async syncObjectType(typeName: CMDBObjectTypeName): Promise { + // This method is deprecated - use syncConfiguredObjectType instead + logger.warn(`SyncEngine: syncObjectType(${typeName}) is deprecated, use configured object types instead`); + return { + objectType: typeName, + objectsProcessed: 0, + relationsExtracted: 0, + duration: 0, + }; + } + // ========================================================================== // Incremental Sync // ========================================================================== /** * Start the incremental sync scheduler + * The scheduler will check configuration on each run and only sync if configuration is complete */ private startIncrementalSyncScheduler(): void { if (this.incrementalTimer) { @@ -300,9 +500,11 @@ class SyncEngine { } logger.info(`SyncEngine: Starting incremental sync scheduler (every ${this.incrementalInterval}ms)`); + logger.info('SyncEngine: Scheduler will only perform syncs when schema configuration is complete'); this.incrementalTimer = setInterval(() => { if (!this.isSyncing && this.isRunning) { + // incrementalSync() will check if configuration is complete before syncing this.incrementalSync().catch(err => { logger.error('SyncEngine: Incremental sync failed', err); }); @@ -324,6 +526,26 @@ class SyncEngine { return { success: false, updatedCount: 0 }; } + // Check if configuration is complete before attempting sync + const { schemaConfigurationService } = await import('./schemaConfigurationService.js'); + const isConfigured = await schemaConfigurationService.isConfigurationComplete(); + if (!isConfigured) { + // Don't log on every interval - only log once per minute to avoid spam + const now = Date.now(); + if (!this.lastConfigCheck || now - this.lastConfigCheck > 60000) { + logger.debug('SyncEngine: Schema configuration not complete, skipping incremental sync. Please configure object types in settings.'); + this.lastConfigCheck = now; + } + return { success: false, updatedCount: 0 }; + } + + // Get enabled object types - will be used later to filter updated objects + const enabledTypes = await schemaConfigurationService.getEnabledObjectTypes(); + if (enabledTypes.length === 0) { + logger.debug('SyncEngine: No enabled object types, skipping incremental sync'); + return { success: false, updatedCount: 0 }; + } + if (this.isSyncing) { return { success: false, updatedCount: 0 }; } @@ -339,6 +561,15 @@ class SyncEngine { logger.debug(`SyncEngine: Incremental sync since ${since.toISOString()}`); + // Get enabled object types to filter incremental sync + const enabledTypes = await schemaConfigurationService.getEnabledObjectTypes(); + const enabledTypeNames = new Set(enabledTypes.map(et => et.objectTypeName)); + + if (enabledTypeNames.size === 0) { + logger.debug('SyncEngine: No enabled object types, skipping incremental sync'); + return { success: false, updatedCount: 0 }; + } + // Fetch updated objects from Jira const updatedObjects = await jiraAssetsClient.getUpdatedObjectsSince(since, this.batchSize); @@ -368,15 +599,49 @@ class SyncEngine { return { success: true, updatedCount: 0 }; } - let updatedCount = 0; + // Schema discovery must be manually triggered via API endpoints + // No automatic discovery + let updatedCount = 0; + const processedIds = new Set(); // Track processed objects for recursive sync + + // Filter updated objects to only process enabled object types + // Use recursive processing to handle nested references for (const jiraObj of updatedObjects) { - const parsed = jiraAssetsClient.parseObject(jiraObj); + const parsed = await jiraAssetsClient.parseObject(jiraObj); if (parsed) { const typeName = parsed._objectType as CMDBObjectTypeName; - await cacheStore.upsertObject(typeName, parsed); - await cacheStore.extractAndStoreRelations(typeName, parsed); - updatedCount++; + + // Only sync if this object type is enabled + if (!enabledTypeNames.has(typeName)) { + logger.debug(`SyncEngine: Skipping ${typeName} in incremental sync - not enabled`); + continue; + } + + // Get raw entry for recursive processing + const objectId = parsed.id; + try { + const entry = await jiraAssetsClient.getObjectEntry(objectId); + if (entry) { + // Use recursive storeObjectTree to process object and all nested references + const result = await this.storeObjectTree(entry, typeName, processedIds); + if (result.objectsStored > 0) { + updatedCount++; + logger.debug(`SyncEngine: Incremental sync processed ${objectId}: ${result.objectsStored} objects, ${result.relationsExtracted} relations`); + } + } else { + // Fallback to linear processing if raw entry not available + await cacheStore.upsertObject(typeName, parsed); + await cacheStore.extractAndStoreRelations(typeName, parsed); + updatedCount++; + } + } catch (error) { + logger.warn(`SyncEngine: Failed to get raw entry for ${objectId}, using fallback`, error); + // Fallback to linear processing + await cacheStore.upsertObject(typeName, parsed); + await cacheStore.extractAndStoreRelations(typeName, parsed); + updatedCount++; + } } } @@ -404,6 +669,7 @@ class SyncEngine { /** * Trigger a sync for a specific object type + * Only syncs if the object type is enabled in configuration * Allows concurrent syncs for different types, but blocks if: * - A full sync is in progress * - An incremental sync is in progress @@ -420,10 +686,19 @@ class SyncEngine { throw new Error(`Sync already in progress for ${typeName}`); } + // Check if this type is enabled in configuration + const { schemaConfigurationService } = await import('./schemaConfigurationService.js'); + const enabledTypes = await schemaConfigurationService.getEnabledObjectTypes(); + const enabledType = enabledTypes.find(et => et.objectTypeName === typeName); + + if (!enabledType) { + throw new Error(`Object type ${typeName} is not enabled for syncing. Please enable it in the Schema Configuration settings page.`); + } + this.syncingTypes.add(typeName); try { - return await this.syncObjectType(typeName); + return await this.syncConfiguredObjectType(enabledType); } finally { this.syncingTypes.delete(typeName); } @@ -431,20 +706,39 @@ class SyncEngine { /** * Force sync a single object + * Only syncs if the object type is enabled in configuration * If the object was deleted from Jira, it will be removed from the local cache + * Uses recursive processing to store nested referenced objects */ async syncObject(typeName: CMDBObjectTypeName, objectId: string): Promise { try { - const jiraObj = await jiraAssetsClient.getObject(objectId); - if (!jiraObj) return false; + // Check if this type is enabled in configuration + const { schemaConfigurationService } = await import('./schemaConfigurationService.js'); + const enabledTypes = await schemaConfigurationService.getEnabledObjectTypes(); + const isEnabled = enabledTypes.some(et => et.objectTypeName === typeName); - const parsed = jiraAssetsClient.parseObject(jiraObj); - if (!parsed) return false; + if (!isEnabled) { + logger.warn(`SyncEngine: Cannot sync object ${objectId} - type ${typeName} is not enabled for syncing`); + return false; + } - await cacheStore.upsertObject(typeName, parsed); - await cacheStore.extractAndStoreRelations(typeName, parsed); + // Schema discovery must be manually triggered via API endpoints + // No automatic discovery + + // Get raw ObjectEntry for recursive processing + const entry = await jiraAssetsClient.getObjectEntry(objectId); + if (!entry) return false; + + // Use recursive storeObjectTree to process object and all nested references + const processedIds = new Set(); + const result = await this.storeObjectTree(entry, typeName, processedIds); - return true; + if (result.objectsStored > 0) { + logger.info(`SyncEngine: Synced object ${objectId} recursively: ${result.objectsStored} objects, ${result.relationsExtracted} relations`); + return true; + } + + return false; } catch (error) { // If object was deleted from Jira, remove it from our cache if (error instanceof JiraObjectNotFoundError) { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..75ef0b5 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,21 @@ +services: + postgres: + image: postgres:15-alpine + container_name: cmdb-postgres-dev + environment: + POSTGRES_DB: cmdb_cache + POSTGRES_USER: cmdb + POSTGRES_PASSWORD: cmdb-dev + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U cmdb"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + postgres_data: diff --git a/docker-compose.prod.acr.yml b/docker-compose.prod.acr.yml index a9a8ec2..b0f2efd 100644 --- a/docker-compose.prod.acr.yml +++ b/docker-compose.prod.acr.yml @@ -2,7 +2,7 @@ version: '3.8' services: backend: - image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest + image: zuyderlandcmdbacr.azurecr.io/cmdb-insight/backend:latest environment: - NODE_ENV=production - PORT=3001 @@ -21,7 +21,7 @@ services: start_period: 40s frontend: - image: zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest + image: zdlas.azurecr.io/cmdb-insight/frontend:latest depends_on: - backend restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index d5415ba..19c4bb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: postgres: image: postgres:15-alpine @@ -33,6 +31,7 @@ services: - DATABASE_NAME=cmdb - DATABASE_USER=cmdb - DATABASE_PASSWORD=cmdb-dev + # Optional Jira/AI variables (set in .env file or environment) - JIRA_HOST=${JIRA_HOST} - JIRA_PAT=${JIRA_PAT} - JIRA_SCHEMA_ID=${JIRA_SCHEMA_ID} diff --git a/docs/AZURE-ACR-DNL-SCOPE.md b/docs/AZURE-ACR-DNL-SCOPE.md index e3870a4..dec6ed2 100644 --- a/docs/AZURE-ACR-DNL-SCOPE.md +++ b/docs/AZURE-ACR-DNL-SCOPE.md @@ -85,7 +85,7 @@ ## 🎯 Aanbeveling voor Jouw Situatie -**Voor Zuyderland CMDB GUI (20 gebruikers, corporate omgeving):** +**Voor CMDB Insight (20 gebruikers, corporate omgeving):** ### Optie A: **"Unsecure"** (Aanbevolen) ⭐ @@ -124,7 +124,7 @@ acrName: 'zuyderlandcmdbacr-abc123' # Met hash! ```yaml # docker-compose.prod.acr.yml -image: zuyderlandcmdbacr-abc123.azurecr.io/zuyderland-cmdb-gui/backend:latest +image: zuyderlandcmdbacr-abc123.azurecr.io/cmdb-insight/backend:latest ``` ```bash diff --git a/docs/AZURE-ACR-PERMISSIONS.md b/docs/AZURE-ACR-PERMISSIONS.md index 68dc134..a2cf70f 100644 --- a/docs/AZURE-ACR-PERMISSIONS.md +++ b/docs/AZURE-ACR-PERMISSIONS.md @@ -2,7 +2,7 @@ ## 🎯 Aanbeveling voor Jouw Situatie -**Voor Zuyderland CMDB GUI (20 gebruikers, corporate tool, productie):** +**Voor CMDB Insight (20 gebruikers, corporate tool, productie):** ### ✅ **RBAC Registry Permissions** (Aanbevolen) ⭐ @@ -80,7 +80,7 @@ ## 🔍 Jouw Situatie Analyse **Jouw setup:** -- 2 repositories: `zuyderland-cmdb-gui/backend` en `zuyderland-cmdb-gui/frontend` +- 2 repositories: `cmdb-insight/backend` en `cmdb-insight/frontend` - 20 gebruikers (klein team) - Corporate tool (interne gebruikers) - Productie omgeving @@ -157,7 +157,7 @@ Met RBAC Registry Permissions kun je deze rollen toewijzen: ## 🎯 Mijn Aanbeveling -**Voor Zuyderland CMDB GUI:** +**Voor CMDB Insight:** ### ✅ **Kies RBAC Registry Permissions** ⭐ diff --git a/docs/AZURE-ACR-PRICING.md b/docs/AZURE-ACR-PRICING.md index 212f2bd..c72cf9d 100644 --- a/docs/AZURE-ACR-PRICING.md +++ b/docs/AZURE-ACR-PRICING.md @@ -2,7 +2,7 @@ ## 🎯 Aanbeveling voor Jouw Situatie -**Voor Zuyderland CMDB GUI (20 gebruikers, corporate tool, productie):** +**Voor CMDB Insight (20 gebruikers, corporate tool, productie):** ### ✅ **Basic SKU** (Aanbevolen) ⭐ @@ -198,7 +198,7 @@ ## 💡 Mijn Aanbeveling -**Voor Zuyderland CMDB GUI:** +**Voor CMDB Insight:** ### ✅ **Kies Basic SKU** ⭐ diff --git a/docs/AZURE-ACR-QUICKSTART.md b/docs/AZURE-ACR-QUICKSTART.md index 5006582..444d0c7 100644 --- a/docs/AZURE-ACR-QUICKSTART.md +++ b/docs/AZURE-ACR-QUICKSTART.md @@ -80,7 +80,7 @@ variables: # Pas deze aan naar jouw ACR naam acrName: 'zuyderlandcmdbacr' # ← Jouw ACR naam hier - repositoryName: 'zuyderland-cmdb-gui' + repositoryName: 'cmdb-insight' # Service connection naam (maak je in volgende stap) dockerRegistryServiceConnection: 'zuyderland-cmdb-acr-connection' @@ -124,7 +124,7 @@ Deze connection geeft Azure DevOps toegang tot je ACR. 2. Klik op **Pipelines** (links in het menu) 3. Klik op **"New pipeline"** of **"Create Pipeline"** 4. Kies **"Azure Repos Git"** (of waar je code staat) -5. Selecteer je repository: **"Zuyderland CMDB GUI"** +5. Selecteer je repository: **"CMDB Insight"** 6. Kies **"Existing Azure Pipelines YAML file"** 7. Selecteer: - **Branch**: `main` @@ -141,8 +141,8 @@ Deze connection geeft Azure DevOps toegang tot je ACR. 1. Ga naar je **Container Registry** (`zuyderlandcmdbacr`) 2. Klik op **"Repositories"** 3. Je zou moeten zien: - - `zuyderland-cmdb-gui/backend` - - `zuyderland-cmdb-gui/frontend` + - `cmdb-insight/backend` + - `cmdb-insight/frontend` 4. Klik op een repository om de tags te zien (bijv. `latest`, `123`) ### Via Azure CLI: @@ -151,10 +151,10 @@ Deze connection geeft Azure DevOps toegang tot je ACR. az acr repository list --name zuyderlandcmdbacr # Lijst tags voor backend -az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend +az acr repository show-tags --name zuyderlandcmdbacr --repository cmdb-insight/backend # Lijst tags voor frontend -az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/frontend +az acr repository show-tags --name zuyderlandcmdbacr --repository cmdb-insight/frontend ``` --- @@ -166,8 +166,8 @@ az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmd az acr login --name zuyderlandcmdbacr # Pull images -docker pull zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest -docker pull zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:latest +docker pull zuyderlandcmdbacr.azurecr.io/cmdb-insight/backend:latest +docker pull zuyderlandcmdbacr.azurecr.io/cmdb-insight/frontend:latest # Test run (met docker-compose) docker-compose -f docker-compose.prod.acr.yml pull diff --git a/docs/AZURE-APP-SERVICE-DEPLOYMENT.md b/docs/AZURE-APP-SERVICE-DEPLOYMENT.md index debc985..bcd1a29 100644 --- a/docs/AZURE-APP-SERVICE-DEPLOYMENT.md +++ b/docs/AZURE-APP-SERVICE-DEPLOYMENT.md @@ -1,11 +1,11 @@ # Azure App Service Deployment - Stap-voor-Stap Guide 🚀 -Complete deployment guide voor Zuyderland CMDB GUI naar Azure App Service. +Complete deployment guide voor CMDB Insight naar Azure App Service. ## 📋 Prerequisites - Azure CLI geïnstalleerd en geconfigureerd (`az login`) -- Docker images in ACR: `zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest` en `frontend:latest` +- Docker images in ACR: `zdlas.azurecr.io/cmdb-insight/backend:latest` en `frontend:latest` - Azure DevOps pipeline werkt (images worden automatisch gebouwd) --- @@ -38,14 +38,14 @@ az webapp create \ --name cmdb-backend-prod \ --resource-group rg-cmdb-gui-prod \ --plan plan-cmdb-gui-prod \ - --deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest + --deployment-container-image-name zdlas.azurecr.io/cmdb-insight/backend:latest # Frontend az webapp create \ --name cmdb-frontend-prod \ --resource-group rg-cmdb-gui-prod \ --plan plan-cmdb-gui-prod \ - --deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest + --deployment-container-image-name zdlas.azurecr.io/cmdb-insight/frontend:latest ``` ### Stap 4: ACR Authentication @@ -70,13 +70,13 @@ az role assignment create --assignee $FRONTEND_PRINCIPAL_ID --role AcrPull --sco az webapp config container set \ --name cmdb-backend-prod \ --resource-group rg-cmdb-gui-prod \ - --docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest \ + --docker-custom-image-name zdlas.azurecr.io/cmdb-insight/backend:latest \ --docker-registry-server-url https://zdlas.azurecr.io az webapp config container set \ --name cmdb-frontend-prod \ --resource-group rg-cmdb-gui-prod \ - --docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest \ + --docker-custom-image-name zdlas.azurecr.io/cmdb-insight/frontend:latest \ --docker-registry-server-url https://zdlas.azurecr.io ``` diff --git a/docs/AZURE-CONTAINER-REGISTRY.md b/docs/AZURE-CONTAINER-REGISTRY.md index 087868f..dbef1c9 100644 --- a/docs/AZURE-CONTAINER-REGISTRY.md +++ b/docs/AZURE-CONTAINER-REGISTRY.md @@ -1,6 +1,6 @@ # Azure Container Registry - Docker Images Build & Push Guide -Deze guide beschrijft hoe je Docker images bouwt en naar Azure Container Registry (ACR) pusht voor de Zuyderland CMDB GUI applicatie. +Deze guide beschrijft hoe je Docker images bouwt en naar Azure Container Registry (ACR) pusht voor de CMDB Insight applicatie. ## 📋 Inhoudsopgave @@ -93,7 +93,7 @@ chmod +x scripts/build-and-push-azure.sh **Environment Variables:** ```bash export ACR_NAME="zuyderlandcmdbacr" -export REPO_NAME="zuyderland-cmdb-gui" +export REPO_NAME="cmdb-insight" ./scripts/build-and-push-azure.sh 1.0.0 ``` @@ -106,7 +106,7 @@ az acr login --name zuyderlandcmdbacr # Set variabelen ACR_NAME="zuyderlandcmdbacr" REGISTRY="${ACR_NAME}.azurecr.io" -REPO_NAME="zuyderland-cmdb-gui" +REPO_NAME="cmdb-insight" VERSION="1.0.0" # Build backend @@ -157,7 +157,7 @@ Pas de variabelen in `azure-pipelines.yml` aan naar jouw instellingen: ```yaml variables: acrName: 'zuyderlandcmdbacr' # Jouw ACR naam - repositoryName: 'zuyderland-cmdb-gui' + repositoryName: 'cmdb-insight' dockerRegistryServiceConnection: 'zuyderland-cmdb-acr-connection' ``` @@ -187,7 +187,7 @@ version: '3.8' services: backend: - image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest + image: zuyderlandcmdbacr.azurecr.io/cmdb-insight/backend:latest environment: - NODE_ENV=production - PORT=3001 @@ -206,7 +206,7 @@ services: start_period: 40s frontend: - image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:latest + image: zuyderlandcmdbacr.azurecr.io/cmdb-insight/frontend:latest depends_on: - backend restart: unless-stopped @@ -249,10 +249,10 @@ Voor productie deployments, gebruik specifieke versies: ```yaml backend: - image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:v1.0.0 + image: zuyderlandcmdbacr.azurecr.io/cmdb-insight/backend:v1.0.0 frontend: - image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:v1.0.0 + image: zuyderlandcmdbacr.azurecr.io/cmdb-insight/frontend:v1.0.0 ``` ### Pull en Deploy @@ -310,10 +310,10 @@ ACR heeft een retention policy voor oude images: ```bash # Retention policy instellen (bijv. laatste 10 tags behouden) -az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend --orderby time_desc --top 10 +az acr repository show-tags --name zuyderlandcmdbacr --repository cmdb-insight/backend --orderby time_desc --top 10 # Oude tags verwijderen (handmatig of via policy) -az acr repository delete --name zuyderlandcmdbacr --image zuyderland-cmdb-gui/backend:old-tag +az acr repository delete --name zuyderlandcmdbacr --image cmdb-insight/backend:old-tag ``` ### 4. Multi-Stage Builds @@ -326,8 +326,8 @@ Voor snellere builds, gebruik build cache: ```bash # Build met cache -docker build --cache-from zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest \ - -t zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:new-tag \ +docker build --cache-from zuyderlandcmdbacr.azurecr.io/cmdb-insight/backend:latest \ + -t zuyderlandcmdbacr.azurecr.io/cmdb-insight/backend:new-tag \ -f backend/Dockerfile.prod ./backend ``` @@ -356,7 +356,7 @@ cat ~/.docker/config.json docker build --progress=plain -t test-image -f backend/Dockerfile.prod ./backend # Check lokale images -docker images | grep zuyderland-cmdb-gui +docker images | grep cmdb-insight ``` ### Push Errors @@ -369,7 +369,7 @@ az acr check-health --name zuyderlandcmdbacr az acr repository list --name zuyderlandcmdbacr # View repository tags -az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend +az acr repository show-tags --name zuyderlandcmdbacr --repository cmdb-insight/backend ``` ### Azure DevOps Pipeline Errors diff --git a/docs/AZURE-DEPLOYMENT-SUMMARY.md b/docs/AZURE-DEPLOYMENT-SUMMARY.md index 96eef11..26bd7f7 100644 --- a/docs/AZURE-DEPLOYMENT-SUMMARY.md +++ b/docs/AZURE-DEPLOYMENT-SUMMARY.md @@ -2,7 +2,7 @@ ## Applicatie Overzicht -**Zuyderland CMDB GUI** - Web applicatie voor classificatie en beheer van applicatiecomponenten in Jira Assets. +**CMDB Insight** - Web applicatie voor classificatie en beheer van applicatiecomponenten in Jira Assets. ### Technologie Stack - **Backend**: Node.js 20 (Express, TypeScript) diff --git a/docs/AZURE-DEVOPS-SETUP.md b/docs/AZURE-DEVOPS-SETUP.md index 194da53..0a0686d 100644 --- a/docs/AZURE-DEVOPS-SETUP.md +++ b/docs/AZURE-DEVOPS-SETUP.md @@ -92,7 +92,7 @@ variables: # Pas deze aan naar jouw ACR naam acrName: 'zuyderlandcmdbacr' # ← Jouw ACR naam hier - repositoryName: 'zuyderland-cmdb-gui' + repositoryName: 'cmdb-insight' # Pas deze aan naar de service connection naam die je net hebt gemaakt dockerRegistryServiceConnection: 'zuyderland-cmdb-acr-connection' # ← Jouw service connection naam @@ -115,7 +115,7 @@ git push origin main 2. Klik op **Pipelines** (links in het menu) 3. Klik op **"New pipeline"** of **"Create Pipeline"** 4. Kies **"Azure Repos Git"** (of waar je code staat) -5. Selecteer je repository: **"Zuyderland CMDB GUI"** (of jouw repo naam) +5. Selecteer je repository: **"CMDB Insight"** (of jouw repo naam) 6. Kies **"Existing Azure Pipelines YAML file"** 7. Selecteer: - **Branch**: `main` @@ -142,8 +142,8 @@ De pipeline start automatisch en zal: **Verwachte output:** ``` -Backend Image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:123 -Frontend Image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:123 +Backend Image: zuyderlandcmdbacr.azurecr.io/cmdb-insight/backend:123 +Frontend Image: zuyderlandcmdbacr.azurecr.io/cmdb-insight/frontend:123 ``` --- @@ -154,8 +154,8 @@ Frontend Image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:123 1. Ga naar je **Container Registry** (`zuyderlandcmdbacr`) 2. Klik op **"Repositories"** 3. Je zou moeten zien: - - `zuyderland-cmdb-gui/backend` - - `zuyderland-cmdb-gui/frontend` + - `cmdb-insight/backend` + - `cmdb-insight/frontend` 4. Klik op een repository om de tags te zien (bijv. `latest`, `123`) **Via Azure CLI:** @@ -164,10 +164,10 @@ Frontend Image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:123 az acr repository list --name zuyderlandcmdbacr # Lijst tags voor backend -az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend +az acr repository show-tags --name zuyderlandcmdbacr --repository cmdb-insight/backend # Lijst tags voor frontend -az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/frontend +az acr repository show-tags --name zuyderlandcmdbacr --repository cmdb-insight/frontend ``` --- @@ -229,7 +229,7 @@ Nu je images in Azure Container Registry staan, kun je ze deployen: # Web App aanmaken en configureren az webapp create --name cmdb-backend --resource-group rg-cmdb-gui --plan plan-cmdb-gui az webapp config container set --name cmdb-backend --resource-group rg-cmdb-gui \ - --docker-custom-image-name zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest \ + --docker-custom-image-name zuyderlandcmdbacr.azurecr.io/cmdb-insight/backend:latest \ --docker-registry-server-url https://zuyderlandcmdbacr.azurecr.io ``` @@ -252,7 +252,7 @@ docker-compose -f docker-compose.prod.acr.yml up -d az container create \ --resource-group rg-cmdb-gui \ --name cmdb-backend \ - --image zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest \ + --image zuyderlandcmdbacr.azurecr.io/cmdb-insight/backend:latest \ --registry-login-server zuyderlandcmdbacr.azurecr.io \ --registry-username \ --registry-password @@ -282,7 +282,7 @@ az acr login --name zuyderlandcmdbacr **Images Lijsten:** ```bash az acr repository list --name zuyderlandcmdbacr -az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend +az acr repository show-tags --name zuyderlandcmdbacr --repository cmdb-insight/backend ``` **Pipeline Handmatig Triggeren:** diff --git a/docs/AZURE-PIPELINE-REPO-TROUBLESHOOTING.md b/docs/AZURE-PIPELINE-REPO-TROUBLESHOOTING.md index fe02eb4..93cf75c 100644 --- a/docs/AZURE-PIPELINE-REPO-TROUBLESHOOTING.md +++ b/docs/AZURE-PIPELINE-REPO-TROUBLESHOOTING.md @@ -19,11 +19,11 @@ Als Azure DevOps je repository niet kan vinden bij het aanmaken van een pipeline 3. **In de pipeline wizard:** - Zoek naar de repository met de exacte naam - Of probeer verschillende variaties: - - `Zuyderland CMDB GUI` - - `zuyderland-cmdb-gui` + - `CMDB Insight` + - `cmdb-insight` - `ZuyderlandCMDBGUI` -**Jouw repository naam zou moeten zijn:** `Zuyderland CMDB GUI` (met spaties) +**Jouw repository naam zou moeten zijn:** `CMDB Insight` (met spaties) --- @@ -65,10 +65,10 @@ Als Azure DevOps je repository niet kan vinden bij het aanmaken van een pipeline **Oplossing:** 1. **Check het project naam** (bovenaan links) - - Moet zijn: **"JiraAssetsCMDB"** + - Moet zijn: **"cmdb"** 2. **Als je in een ander project bent:** - Klik op het project dropdown (bovenaan links) - - Selecteer **"JiraAssetsCMDB"** + - Selecteer **"cmdb"** 3. **Probeer opnieuw** de pipeline aan te maken --- @@ -90,7 +90,7 @@ Als Azure DevOps je repository niet kan vinden bij het aanmaken van een pipeline **Oplossing:** 1. **Ga naar Repos** (links in het menu) 2. **Check of je repository zichtbaar is** - - Je zou moeten zien: `Zuyderland CMDB GUI` (of jouw repo naam) + - Je zou moeten zien: `CMDB Insight` (of jouw repo naam) 3. **Als de repository niet bestaat:** - Je moet eerst de code pushen naar Azure DevOps - Of de repository aanmaken in Azure DevOps @@ -111,12 +111,12 @@ Als Azure DevOps je repository niet kan vinden bij het aanmaken van een pipeline 1. **Check de repository URL:** ``` - git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/JiraAssetsCMDB/Zuyderland%20CMDB%20GUI + git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/cmdb/cmdb-insight ``` 2. **Push je code:** ```bash - cd /Users/berthausmans/Documents/Development/zuyderland-cmdb-gui + cd /Users/berthausmans/Documents/Development/cmdb-insight git push azure main ``` @@ -131,13 +131,13 @@ Als Azure DevOps je repository niet kan vinden bij het aanmaken van een pipeline 1. **Ga naar Repos** (links in het menu) 2. **Klik op "New repository"** of het "+" icoon 3. **Vul in:** - - **Repository name**: `Zuyderland CMDB GUI` + - **Repository name**: `CMDB Insight` - **Type**: Git 4. **Create** 5. **Push je code:** ```bash - cd /Users/berthausmans/Documents/Development/zuyderland-cmdb-gui - git remote add azure git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/JiraAssetsCMDB/Zuyderland%20CMDB%20GUI + cd /Users/berthausmans/Documents/Development/cmdb-insight + git remote add azure git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/cmdb/cmdb-insight git push azure main ``` @@ -150,8 +150,8 @@ Als Azure DevOps je repository niet kan vinden bij het aanmaken van een pipeline 1. **In de pipeline wizard:** - Kies **"Other Git"** (in plaats van "Azure Repos Git") 2. **Vul in:** - - **Repository URL**: `git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/JiraAssetsCMDB/Zuyderland%20CMDB%20GUI` - - Of HTTPS: `https://ZuyderlandMedischCentrum@dev.azure.com/ZuyderlandMedischCentrum/JiraAssetsCMDB/_git/Zuyderland%20CMDB%20GUI` + - **Repository URL**: `git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/cmdb/cmdb-insight` + - Of HTTPS: `https://ZuyderlandMedischCentrum@dev.azure.com/ZuyderlandMedischCentrum/cmdb/_git/cmdb-insight` 3. **Branch**: `main` 4. **Continue** @@ -166,21 +166,21 @@ Als Azure DevOps je repository niet kan vinden bij het aanmaken van een pipeline ### 1. Check of Repository Bestaat 1. Ga naar **Repos** (links in het menu) -2. Check of je `Zuyderland CMDB GUI` ziet +2. Check of je `CMDB Insight` ziet 3. Klik erop en check of je code ziet ### 2. Check Repository URL **In Terminal:** ```bash -cd /Users/berthausmans/Documents/Development/zuyderland-cmdb-gui +cd /Users/berthausmans/Documents/Development/cmdb-insight git remote -v ``` **Je zou moeten zien:** ``` -azure git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/JiraAssetsCMDB/Zuyderland%20CMDB%20GUI (fetch) -azure git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/JiraAssetsCMDB/Zuyderland%20CMDB%20GUI (push) +azure git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/cmdb/cmdb-insight (fetch) +azure git@ssh.dev.azure.com:v3/ZuyderlandMedischCentrum/cmdb/cmdb-insight (push) ``` ### 3. Check of Code Gepusht is @@ -206,7 +206,7 @@ git push azure main **Probeer in deze volgorde:** 1. ✅ **Check Repos** - Ga naar Repos en check of je repository bestaat -2. ✅ **Check project naam** - Zorg dat je in "JiraAssetsCMDB" project bent +2. ✅ **Check project naam** - Zorg dat je in "cmdb" project bent 3. ✅ **Refresh pagina** - Soms helpt een simpele refresh 4. ✅ **Push code** - Als repository leeg is, push je code 5. ✅ **Gebruik "Other Git"** - Als workaround @@ -223,7 +223,7 @@ git push azure main 2. **Als repository leeg is:** ```bash - cd /Users/berthausmans/Documents/Development/zuyderland-cmdb-gui + cd /Users/berthausmans/Documents/Development/cmdb-insight git push azure main ``` @@ -242,7 +242,7 @@ git push azure main Als niets werkt: -1. **Check of je in het juiste project bent** (JiraAssetsCMDB) +1. **Check of je in het juiste project bent** (cmdb) 2. **Check of de repository bestaat** (Repos → Files) 3. **Push je code** naar Azure DevOps 4. **Gebruik "Other Git"** als workaround diff --git a/docs/AZURE-QUICK-REFERENCE.md b/docs/AZURE-QUICK-REFERENCE.md index da61a98..f6c288d 100644 --- a/docs/AZURE-QUICK-REFERENCE.md +++ b/docs/AZURE-QUICK-REFERENCE.md @@ -2,7 +2,7 @@ ## 🎯 In één oogopslag -**Applicatie**: Zuyderland CMDB GUI (Node.js + React web app) +**Applicatie**: CMDB Insight (Node.js + React web app) **Doel**: Hosten in Azure App Service **Gebruikers**: Max. 20 collega's **Geschatte kosten**: €18-39/maand (Basic tier) diff --git a/docs/AZURE-REGISTRY-BESLISSING.md b/docs/AZURE-REGISTRY-BESLISSING.md index 190de58..a610a91 100644 --- a/docs/AZURE-REGISTRY-BESLISSING.md +++ b/docs/AZURE-REGISTRY-BESLISSING.md @@ -171,11 +171,11 @@ Onderwerp: Azure Container Registry aanvraag - CMDB GUI Project Beste IT Team, -Voor het Zuyderland CMDB GUI project hebben we een Azure Container Registry nodig +Voor het CMDB Insight project hebben we een Azure Container Registry nodig voor het hosten van Docker images. Details: -- Project: Zuyderland CMDB GUI +- Project: CMDB Insight - Registry naam: zuyderlandcmdbacr (of zoals jullie naming convention) - SKU: Basic (voor development/productie) - Resource Group: rg-cmdb-gui diff --git a/docs/AZURE-SERVICE-CONNECTION-AUTH.md b/docs/AZURE-SERVICE-CONNECTION-AUTH.md index 790e626..9edd2f4 100644 --- a/docs/AZURE-SERVICE-CONNECTION-AUTH.md +++ b/docs/AZURE-SERVICE-CONNECTION-AUTH.md @@ -2,7 +2,7 @@ ## 🎯 Aanbeveling voor Jouw Situatie -**Voor Zuyderland CMDB GUI met Azure Container Registry:** +**Voor CMDB Insight met Azure Container Registry:** ### ✅ **Service Principal** (Aanbevolen) ⭐ @@ -184,7 +184,7 @@ Wanneer je **Service Principal** kiest: ## 💡 Mijn Aanbeveling -**Voor Zuyderland CMDB GUI:** +**Voor CMDB Insight:** ### ✅ **Kies Service Principal** ⭐ diff --git a/docs/DATA-INTEGRITY-PLAN.md b/docs/DATA-INTEGRITY-PLAN.md new file mode 100644 index 0000000..8ea5aa2 --- /dev/null +++ b/docs/DATA-INTEGRITY-PLAN.md @@ -0,0 +1,313 @@ +# Data Integriteit Plan - Voorkomen van Kapotte Referenties + +## Probleem +Kapotte referenties ontstaan wanneer `attribute_values.reference_object_id` verwijst naar objecten die niet in de `objects` tabel bestaan. Dit kan gebeuren door: +1. Objecten verwijderd uit Jira maar referenties blijven bestaan +2. Onvolledige sync (niet alle gerelateerde objecten zijn gesynchroniseerd) +3. Objecten verwijderd uit cache maar referenties blijven bestaan +4. Race conditions tijdens sync + +## Strategie: Multi-layer Aanpak + +### Laag 1: Preventie tijdens Sync (Hoogste Prioriteit) + +#### 1.1 Referenced Object Validation tijdens Sync +**Doel**: Zorg dat alle referenced objects bestaan voordat we referenties opslaan + +**Implementatie**: +- Voeg validatie toe in `extractAndStoreRelations()` en `normalizeObjectWithDb()` +- Voor elke reference: check of target object bestaat in cache +- Als target niet bestaat: fetch het object eerst van Jira +- Als object niet in Jira bestaat: sla referentie NIET op (of markeer als "missing") + +**Code locatie**: `backend/src/services/normalizedCacheStore.ts` + +#### 1.2 Deep Sync voor Referenced Objects +**Doel**: Sync automatisch alle referenced objects tijdens object sync + +**Implementatie**: +- Wanneer een object wordt gesynct, identificeer alle referenced objects +- Queue deze referenced objects voor sync (als ze niet recent gesynct zijn) +- Voer sync uit in dependency order (sync referenced objects eerst) + +**Code locatie**: `backend/src/services/syncEngine.ts` + +#### 1.3 Transactional Reference Storage +**Doel**: Zorg dat object + referenties atomisch worden opgeslagen + +**Implementatie**: +- Gebruik database transactions voor object + referenties +- Rollback als referenced object niet bestaat +- Valideer alle references voordat commit + +**Code locatie**: `backend/src/services/normalizedCacheStore.ts` + +### Laag 2: Database Constraints (Waar Mogelijk) + +#### 2.1 Foreign Key Constraints voor Referenties +**Doel**: Database-level validatie van referenties + +**Huidige situatie**: +- `attribute_values.reference_object_id` heeft GEEN foreign key constraint +- Dit is bewust omdat referenced objects mogelijk niet in cache zijn + +**Optie A: Soft Foreign Key (Aanbevolen)** +- Voeg een CHECK constraint toe die valideert dat reference_object_id NULL is OF bestaat in objects +- Dit vereist een database trigger of function + +**Optie B: Staging Table** +- Sla nieuwe referenties eerst op in een staging table +- Valideer en migreer alleen valide referenties naar attribute_values +- Cleanup staging table periodiek + +**Implementatie**: Database migration + trigger/function + +#### 2.2 Database Triggers voor Cleanup +**Doel**: Automatische cleanup wanneer objecten worden verwijderd + +**Implementatie**: +- Trigger op DELETE van objects +- Automatisch verwijder of nullify alle attribute_values met reference_object_id = deleted.id +- Log cleanup acties voor audit + +**Code locatie**: Database migration + +### Laag 3: Validatie en Cleanup Procedures + +#### 3.1 Periodieke Validatie Job +**Doel**: Detecteer en repareer kapotte referenties automatisch + +**Implementatie**: +- Dagelijkse/nachtelijke job die alle broken references detecteert +- Probeer referenced objects te fetchen van Jira +- Als object bestaat: sync en repareer referentie +- Als object niet bestaat: verwijder referentie (of markeer als "deleted") + +**Code locatie**: `backend/src/services/dataIntegrityService.ts` (nieuw) + +**Scheduling**: Via cron job of scheduled task + +#### 3.2 Manual Cleanup Endpoint +**Doel**: Admin tool om broken references te repareren + +**Implementatie**: +- POST `/api/data-validation/repair-broken-references` +- Opties: + - `mode: 'delete'` - Verwijder kapotte referenties + - `mode: 'fetch'` - Probeer objects te fetchen van Jira + - `mode: 'dry-run'` - Toon wat er zou gebeuren zonder wijzigingen + +**Code locatie**: `backend/src/routes/dataValidation.ts` + +#### 3.3 Reference Validation tijdens Object Retrieval +**Doel**: Valideer en repareer referenties wanneer objecten worden opgehaald + +**Implementatie**: +- In `reconstructObject()`: check alle references +- Als reference broken is: probeer te fetchen van Jira +- Als fetch succesvol: update cache en referentie +- Als fetch faalt: markeer als "missing" in response + +**Code locatie**: `backend/src/services/normalizedCacheStore.ts` + +### Laag 4: Sync Verbeteringen + +#### 4.1 Dependency-Aware Sync Order +**Doel**: Sync objecten in de juiste volgorde (dependencies eerst) + +**Implementatie**: +- Analyseer schema om dependency graph te bouwen +- Sync object types in dependency order +- Bijvoorbeeld: sync "Application Function" voordat "Application Component" + +**Code locatie**: `backend/src/services/syncEngine.ts` + +#### 4.2 Batch Sync met Reference Resolution +**Doel**: Sync batches van objecten en resolve alle references + +**Implementatie**: +- Verzamel alle referenced object IDs tijdens batch sync +- Fetch alle referenced objects in parallel +- Valideer alle references voordat batch commit + +**Code locatie**: `backend/src/services/syncEngine.ts` + +#### 4.3 Incremental Sync met Deletion Detection +**Doel**: Detecteer verwijderde objecten tijdens incremental sync + +**Implementatie**: +- Vergelijk cache objecten met Jira objecten +- Identificeer objecten die in cache zijn maar niet in Jira +- Verwijder deze objecten (cascade verwijdert referenties) + +**Code locatie**: `backend/src/services/syncEngine.ts` + +### Laag 5: Monitoring en Alerting + +#### 5.1 Metrics en Dashboard +**Doel**: Monitor data integriteit in real-time + +**Implementatie**: +- Track broken references count over tijd +- Alert wanneer count boven threshold komt +- Grafiek in dashboard: broken references trend + +**Code locatie**: `backend/src/routes/dataValidation.ts` + dashboard + +#### 5.2 Sync Health Checks +**Doel**: Valideer data integriteit na elke sync + +**Implementatie**: +- Na sync: check voor broken references +- Log warning als broken references gevonden +- Optioneel: auto-repair tijdens sync + +**Code locatie**: `backend/src/services/syncEngine.ts` + +#### 5.3 Audit Logging +**Doel**: Track alle data integriteit acties + +**Implementatie**: +- Log wanneer broken references worden gerepareerd +- Log wanneer objecten worden verwijderd +- Log wanneer references worden toegevoegd/verwijderd + +**Code locatie**: Logger service + +## Implementatie Prioriteiten + +### Fase 1: Quick Wins (Week 1) +1. ✅ Manual cleanup endpoint (`/api/data-validation/repair-broken-references`) +2. ✅ Periodieke validatie job (dagelijks) +3. ✅ Metrics in dashboard + +### Fase 2: Preventie (Week 2-3) +4. Referenced object validation tijdens sync +5. Deep sync voor referenced objects +6. Transactional reference storage + +### Fase 3: Database Level (Week 4) +7. Database triggers voor cleanup +8. Soft foreign key constraints (waar mogelijk) + +### Fase 4: Advanced (Week 5+) +9. Dependency-aware sync order +10. Incremental sync met deletion detection +11. Auto-repair tijdens object retrieval + +## Technische Details + +### Referenced Object Validation Pattern + +```typescript +async function validateAndFetchReference( + referenceObjectId: string, + targetType?: string +): Promise<{ exists: boolean; object?: CMDBObject }> { + // 1. Check cache first + const cached = await cacheStore.getObjectById(referenceObjectId); + if (cached) return { exists: true, object: cached }; + + // 2. Try to fetch from Jira + try { + const jiraObj = await jiraAssetsClient.getObject(referenceObjectId); + if (jiraObj) { + // Parse and cache + const parsed = jiraAssetsClient.parseObject(jiraObj); + if (parsed) { + await cacheStore.upsertObject(parsed._objectType, parsed); + return { exists: true, object: parsed }; + } + } + } catch (error) { + if (error instanceof JiraObjectNotFoundError) { + return { exists: false }; + } + throw error; + } + + return { exists: false }; +} +``` + +### Cleanup Procedure + +```typescript +async function repairBrokenReferences(mode: 'delete' | 'fetch' | 'dry-run'): Promise<{ + total: number; + repaired: number; + deleted: number; + failed: number; +}> { + const brokenRefs = await cacheStore.getBrokenReferences(10000, 0); + let repaired = 0; + let deleted = 0; + let failed = 0; + + for (const ref of brokenRefs) { + if (mode === 'fetch') { + // Try to fetch from Jira + const result = await validateAndFetchReference(ref.reference_object_id); + if (result.exists) { + repaired++; + } else { + // Object doesn't exist, delete reference + await cacheStore.deleteAttributeValue(ref.object_id, ref.attribute_id); + deleted++; + } + } else if (mode === 'delete') { + await cacheStore.deleteAttributeValue(ref.object_id, ref.attribute_id); + deleted++; + } + // dry-run: just count + } + + return { total: brokenRefs.length, repaired, deleted, failed }; +} +``` + +## Success Criteria + +- ✅ Broken references count < 1% van totaal aantal referenties +- ✅ Geen nieuwe broken references tijdens normale sync +- ✅ Auto-repair binnen 24 uur na detectie +- ✅ Monitoring dashboard toont real-time integriteit status +- ✅ Sync performance blijft acceptabel (< 10% overhead) + +## Monitoring + +### Key Metrics +- `broken_references_count` - Totaal aantal kapotte referenties +- `broken_references_rate` - Percentage van totaal aantal referenties +- `reference_repair_success_rate` - Percentage succesvol gerepareerde referenties +- `sync_integrity_check_duration` - Tijd voor integriteit check +- `objects_with_broken_refs` - Aantal objecten met kapotte referenties + +### Alerts +- 🔴 Critical: > 5% broken references +- 🟡 Warning: > 1% broken references +- 🟢 Info: Broken references count veranderd + +## Rollout Plan + +1. **Week 1**: Implementeer cleanup tools en monitoring +2. **Week 2**: Repareer bestaande broken references +3. **Week 3**: Implementeer preventieve maatregelen +4. **Week 4**: Test en monitor +5. **Week 5**: Database-level constraints (optioneel) + +## Risico's en Mitigatie + +| Risico | Impact | Mitigatie | +|--------|--------|-----------| +| Sync performance degradation | Hoog | Batch processing, parallel fetching, caching | +| Database locks tijdens cleanup | Medium | Off-peak scheduling, batch size limits | +| False positives in validation | Low | Dry-run mode, manual review | +| Jira API rate limits | Medium | Rate limiting, exponential backoff | + +## Documentatie Updates + +- [ ] Update sync engine documentatie +- [ ] Update data validation dashboard documentatie +- [ ] Create runbook voor broken references cleanup +- [ ] Update API documentatie voor nieuwe endpoints diff --git a/docs/DATABASE-DRIVEN-SCHEMA-IMPLEMENTATION-PLAN.md b/docs/DATABASE-DRIVEN-SCHEMA-IMPLEMENTATION-PLAN.md new file mode 100644 index 0000000..11f46c9 --- /dev/null +++ b/docs/DATABASE-DRIVEN-SCHEMA-IMPLEMENTATION-PLAN.md @@ -0,0 +1,211 @@ +# Database-Driven Schema Implementation Plan + +## Overzicht + +Dit plan beschrijft de migratie van statische schema files naar een volledig database-driven aanpak waarbij: +1. Schema wordt dynamisch opgehaald van Jira Assets API +2. Schema wordt opgeslagen in PostgreSQL database +3. Datamodel en datavalidatie pagina's worden opgebouwd vanuit database +4. TypeScript types worden gegenereerd vanuit database (handmatig) + +## Architectuur + +``` +┌─────────────────┐ +│ Jira Assets API │ (Authoritative Source) +└────────┬────────┘ + │ + │ Schema Discovery + ▼ +┌─────────────────┐ +│ Schema Discovery│ (Jira API → Database) +│ Service │ +└────────┬────────┘ + │ + │ Store in DB + ▼ +┌─────────────────┐ +│ PostgreSQL DB │ (Cached Schema) +│ - object_types │ +│ - attributes │ +└────────┬────────┘ + │ + │ Serve to Frontend + ▼ +┌─────────────────┐ +│ API Endpoints │ (/api/schema) +└────────┬────────┘ + │ + │ Code Generation + ▼ +┌─────────────────┐ +│ TypeScript Types │ (Handmatig gegenereerd) +└─────────────────┘ +``` + +## Database Schema + +De database heeft al de benodigde tabellen in `normalized-schema.ts`: +- `object_types` - Object type definities +- `attributes` - Attribute definities per object type + +**Geen extra tabellen nodig!** We gebruiken de bestaande structuur. + +## Implementatie Stappen + +### Stap 1: Schema Discovery Service Aanpassen ✅ + +**Huidige situatie:** +- `schemaDiscoveryService` haalt data uit statische `OBJECT_TYPES` file + +**Nieuwe situatie:** +- `schemaDiscoveryService` haalt schema direct van Jira Assets API +- Gebruikt `JiraSchemaFetcher` logica (uit `generate-schema.ts`) +- Slaat schema op in database tabellen + +**Bestanden:** +- `backend/src/services/schemaDiscoveryService.ts` - Aanpassen om API calls te maken + +### Stap 2: Schema Cache Service ✅ + +**Nieuwe service:** +- In-memory cache met 5 minuten TTL +- Cache invalidation bij schema updates +- Snelle response voor `/api/schema` endpoint + +**Bestanden:** +- `backend/src/services/schemaCacheService.ts` - Nieuw bestand + +### Stap 3: Schema API Endpoint Migreren ✅ + +**Huidige situatie:** +- `/api/schema` endpoint leest van statische `OBJECT_TYPES` file + +**Nieuwe situatie:** +- `/api/schema` endpoint leest van database (via cache) +- Gebruikt `schemaCacheService` voor performance + +**Bestanden:** +- `backend/src/routes/schema.ts` - Aanpassen om database te gebruiken + +### Stap 4: Code Generation Script ✅ + +**Nieuwe functionaliteit:** +- Script dat database schema → TypeScript types genereert +- Handmatig uitvoerbaar via CLI command +- Genereert: `jira-schema.ts`, `jira-types.ts` + +**Bestanden:** +- `backend/scripts/generate-types-from-db.ts` - Nieuw bestand +- `package.json` - NPM script toevoegen + +### Stap 5: Datavalidatie Pagina Migreren ✅ + +**Huidige situatie:** +- Gebruikt mogelijk statische schema files + +**Nieuwe situatie:** +- Volledig database-driven +- Gebruikt `schemaDiscoveryService` voor schema data + +**Bestanden:** +- `backend/src/routes/dataValidation.ts` - Controleren en aanpassen indien nodig + +### Stap 6: Database Indexes ✅ + +**Toevoegen:** +- Indexes voor snelle schema queries +- Performance optimalisatie + +**Bestanden:** +- `backend/src/services/database/normalized-schema.ts` - Indexes toevoegen + +### Stap 7: CLI Command voor Schema Discovery ✅ + +**Nieuwe functionaliteit:** +- Handmatige trigger voor schema discovery +- Bijvoorbeeld: `npm run discover-schema` + +**Bestanden:** +- `backend/scripts/discover-schema.ts` - Nieuw bestand +- `package.json` - NPM script toevoegen + +## API Endpoints + +### GET /api/schema +**Huidig:** Leest van statische files +**Nieuw:** Leest van database (via cache) + +**Response format:** Ongewijzigd (backward compatible) + +### POST /api/schema/discover (Nieuw) +**Functionaliteit:** Handmatige trigger voor schema discovery +**Gebruik:** Admin endpoint voor handmatige schema refresh + +## Code Generation + +### Script: `generate-types-from-db.ts` +**Input:** Database schema (object_types, attributes) +**Output:** +- `backend/src/generated/jira-schema.ts` +- `backend/src/generated/jira-types.ts` + +**Uitvoering:** Handmatig via `npm run generate-types` + +## Migratie Strategie + +1. **Parallelle implementatie:** Nieuwe code naast oude code +2. **Feature flag:** Optioneel om tussen oude/nieuwe aanpak te switchen +3. **Testing:** Uitgebreide tests voor schema discovery +4. **Handmatige migratie:** Breaking changes worden handmatig opgelost + +## Performance Overwegingen + +- **In-memory cache:** 5 minuten TTL voor schema endpoints +- **Database indexes:** Voor snelle queries op object_types en attributes +- **Lazy loading:** Schema wordt alleen geladen wanneer nodig + +## Breaking Changes + +- **Geen fallback:** Als database schema niet beschikbaar is, werkt niets +- **TypeScript errors:** Bij schema wijzigingen ontstaan compile errors +- **Handmatige fix:** Developers lossen errors handmatig op + +## Testing Checklist + +- [ ] Schema discovery van Jira API werkt +- [ ] Schema wordt correct opgeslagen in database +- [ ] `/api/schema` endpoint retourneert database data +- [ ] Cache werkt correct (TTL, invalidation) +- [ ] Code generation script werkt +- [ ] Datamodel pagina toont database data +- [ ] Datavalidatie pagina toont database data +- [ ] Handmatige schema discovery trigger werkt + +## Rollout Plan + +1. **Fase 1:** Schema discovery service aanpassen (API calls) +2. **Fase 2:** Schema cache service implementeren +3. **Fase 3:** API endpoints migreren +4. **Fase 4:** Code generation script maken +5. **Fase 5:** Testing en validatie +6. **Fase 6:** Oude statische files verwijderen (na handmatige migratie) + +## Risico's en Mitigatie + +| Risico | Impact | Mitigatie | +|--------|--------|-----------| +| Jira API niet beschikbaar | Hoog | Geen fallback - downtime acceptabel | +| Schema wijzigingen | Medium | TypeScript errors - handmatig oplossen | +| Performance issues | Laag | Cache + indexes | +| Data migratie fouten | Medium | Uitgebreide tests | + +## Success Criteria + +✅ Schema wordt dynamisch opgehaald van Jira API +✅ Schema wordt opgeslagen in database +✅ Datamodel pagina toont database data +✅ Datavalidatie pagina toont database data +✅ Code generation script werkt +✅ Handmatige schema discovery werkt +✅ Performance is acceptabel (< 1s voor schema endpoint) diff --git a/docs/DATABASE-NORMALIZATION-PROPOSAL.md b/docs/DATABASE-NORMALIZATION-PROPOSAL.md new file mode 100644 index 0000000..024689f --- /dev/null +++ b/docs/DATABASE-NORMALIZATION-PROPOSAL.md @@ -0,0 +1,197 @@ +# Database Normalisatie Voorstel + +## Huidige Probleem + +De huidige database structuur heeft duplicatie en is niet goed genormaliseerd: + +1. **`object_types`** tabel bevat: + - `jira_type_id`, `type_name`, `display_name`, `description`, `sync_priority`, `object_count` + +2. **`configured_object_types`** tabel bevat: + - `schema_id`, `schema_name`, `object_type_id`, `object_type_name`, `display_name`, `description`, `object_count`, `enabled` + +**Problemen:** +- Duplicatie van `display_name`, `description`, `object_count` +- Geen expliciete relatie tussen schemas en object types +- `schema_name` wordt opgeslagen in elke object type row (niet genormaliseerd) +- Verwarring tussen `object_type_name` en `type_name` +- Twee tabellen die dezelfde informatie bevatten + +## Voorgestelde Genormaliseerde Structuur + +### 1. `schemas` Tabel + +```sql +CREATE TABLE IF NOT EXISTS schemas ( + id SERIAL PRIMARY KEY, -- Auto-increment PK + jira_schema_id TEXT NOT NULL UNIQUE, -- Jira schema ID (bijv. "6", "8") + name TEXT NOT NULL, -- Schema naam (bijv. "Application Management") + description TEXT, -- Optionele beschrijving + discovered_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +**Doel:** Centrale opslag van alle Jira Assets schemas. + +### 2. `object_types` Tabel (Aangepast) + +```sql +CREATE TABLE IF NOT EXISTS object_types ( + id SERIAL PRIMARY KEY, -- Auto-increment PK + schema_id INTEGER NOT NULL REFERENCES schemas(id) ON DELETE CASCADE, + jira_type_id INTEGER NOT NULL, -- Jira object type ID + type_name TEXT NOT NULL UNIQUE, -- PascalCase type name (bijv. "ApplicationComponent") + display_name TEXT NOT NULL, -- Original Jira name (bijv. "Application Component") + description TEXT, -- Optionele beschrijving + sync_priority INTEGER DEFAULT 0, -- Sync prioriteit + object_count INTEGER DEFAULT 0, -- Aantal objecten in Jira + enabled BOOLEAN NOT NULL DEFAULT FALSE, -- KEY CHANGE: enabled flag hier! + discovered_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(schema_id, jira_type_id) -- Een object type kan maar 1x per schema voorkomen +); +``` + +**Doel:** Alle object types met hun schema relatie en enabled status. + +### 3. `attributes` Tabel (Ongewijzigd) + +```sql +CREATE TABLE IF NOT EXISTS attributes ( + id SERIAL PRIMARY KEY, + jira_attr_id INTEGER NOT NULL, + object_type_name TEXT NOT NULL REFERENCES object_types(type_name) ON DELETE CASCADE, + -- ... rest blijft hetzelfde +); +``` + +## Voordelen van Genormaliseerde Structuur + +1. **Geen Duplicatie:** + - Schema informatie staat maar 1x in `schemas` tabel + - Object type informatie staat maar 1x in `object_types` tabel + - `enabled` flag staat direct bij object type + +2. **Duidelijke Relaties:** + - Foreign key `schema_id` maakt relatie expliciet + - Database constraints zorgen voor data integriteit + +3. **Eenvoudigere Queries:** + ```sql + -- Alle enabled object types met hun schema + SELECT ot.*, s.name as schema_name + FROM object_types ot + JOIN schemas s ON ot.schema_id = s.id + WHERE ot.enabled = TRUE; + ``` + +4. **Minder Verwarring:** + - Geen `object_type_name` vs `type_name` meer + - Geen `configured_object_types` vs `object_types` meer + - Eén bron van waarheid + +5. **Eenvoudigere Migratie:** + - `configured_object_types` kan worden verwijderd + - Data kan worden gemigreerd naar nieuwe structuur + +## Migratie Plan + +1. **Nieuwe Tabellen Aanmaken:** + - `schemas` tabel + - `object_types` tabel aanpassen (toevoegen `schema_id`, `enabled`) + +2. **Data Migreren:** + - Unieke schemas uit `configured_object_types` naar `schemas` + - Object types uit `configured_object_types` naar `object_types` met juiste `schema_id` FK + - `enabled` flag overnemen + +3. **Foreign Keys Aanpassen:** + - `attributes.object_type_name` blijft verwijzen naar `object_types.type_name` + - `objects.object_type_name` blijft verwijzen naar `object_types.type_name` + +4. **Code Aanpassen:** + - `schemaConfigurationService` aanpassen voor nieuwe structuur + - `schemaDiscoveryService` aanpassen voor nieuwe structuur + - `schemaCacheService` aanpassen voor JOIN met `schemas` + +5. **Oude Tabel Verwijderen:** + - `configured_object_types` tabel verwijderen na migratie + +## Impact op Bestaande Code + +### Services die aangepast moeten worden: + +1. **`schemaConfigurationService.ts`:** + - `discoverAndStoreSchemasAndObjectTypes()` - eerst schemas opslaan, dan object types + - `getConfiguredObjectTypes()` - JOIN met schemas + - `setObjectTypeEnabled()` - direct op object_types.enabled + - `getEnabledObjectTypes()` - WHERE enabled = TRUE + +2. **`schemaDiscoveryService.ts`:** + - Moet ook schemas en object types in nieuwe structuur opslaan + - Moet `enabled` flag respecteren + +3. **`schemaCacheService.ts`:** + - `fetchFromDatabase()` - JOIN met schemas voor schema naam + - Filter op `object_types.enabled = TRUE` + +4. **`syncEngine.ts`:** + - Gebruikt al `getEnabledObjectTypes()` - blijft werken na aanpassing service + +## SQL Migratie Script + +```sql +-- Stap 1: Maak schemas tabel +CREATE TABLE IF NOT EXISTS schemas ( + id SERIAL PRIMARY KEY, + jira_schema_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + discovered_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Stap 2: Voeg schema_id en enabled toe aan object_types +ALTER TABLE object_types + ADD COLUMN IF NOT EXISTS schema_id INTEGER REFERENCES schemas(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT FALSE; + +-- Stap 3: Migreer data +-- Eerst: unieke schemas +INSERT INTO schemas (jira_schema_id, name, description, discovered_at, updated_at) +SELECT DISTINCT + schema_id as jira_schema_id, + schema_name as name, + NULL as description, + MIN(discovered_at) as discovered_at, + MAX(updated_at) as updated_at +FROM configured_object_types +GROUP BY schema_id, schema_name +ON CONFLICT(jira_schema_id) DO NOTHING; + +-- Dan: object types met schema_id FK +UPDATE object_types ot +SET + schema_id = s.id, + enabled = COALESCE( + (SELECT enabled FROM configured_object_types cot + WHERE cot.object_type_id = ot.jira_type_id + AND cot.schema_id = s.jira_schema_id + LIMIT 1), + FALSE + ) +FROM schemas s +WHERE EXISTS ( + SELECT 1 FROM configured_object_types cot + WHERE cot.object_type_id = ot.jira_type_id + AND cot.schema_id = s.jira_schema_id +); + +-- Stap 4: Verwijder oude tabel (na verificatie) +-- DROP TABLE IF EXISTS configured_object_types; +``` + +## Conclusie + +De genormaliseerde structuur is veel cleaner, elimineert duplicatie, en maakt queries eenvoudiger. De `enabled` flag staat nu direct bij het object type, wat logischer is. diff --git a/docs/DATABASE-RESET-GUIDE.md b/docs/DATABASE-RESET-GUIDE.md new file mode 100644 index 0000000..bae60c8 --- /dev/null +++ b/docs/DATABASE-RESET-GUIDE.md @@ -0,0 +1,310 @@ +# Complete Database Reset Guide + +This guide explains how to force a complete reset of the database so that the cache/data is built completely from scratch. + +## Overview + +There are three main approaches to resetting the database: + +1. **API-based reset** (Recommended) - Clears cache via API and triggers rebuild +2. **Database volume reset** - Completely removes PostgreSQL volume and recreates +3. **Manual SQL reset** - Direct database commands to clear all data + +## Option 1: API-Based Reset (Recommended) + +This is the cleanest approach as it uses the application's built-in endpoints. + +### Using the Script + +```bash +# Run the automated reset script +./scripts/reset-and-rebuild.sh +``` + +The script will: +1. Clear all cached data via `DELETE /api/cache/clear` +2. Trigger a full sync via `POST /api/cache/sync` +3. Monitor the process + +### Manual API Calls + +If you prefer to do it manually: + +```bash +# Set your backend URL +BACKEND_URL="http://localhost:3001" +API_URL="$BACKEND_URL/api" + +# Step 1: Clear all cache +curl -X DELETE "$API_URL/cache/clear" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" + +# Step 2: Trigger full sync +curl -X POST "$API_URL/cache/sync" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +**Note:** You need to be authenticated. Either: +- Use a valid JWT token from your session +- Or authenticate via the UI first and copy the token from browser dev tools + +### Via the UI + +1. Navigate to **Settings → Cache Management** +2. Click **"Clear All Cache"** button +3. Click **"Full Sync"** button +4. Monitor progress in the same page + +## Option 2: Database Volume Reset (Nuclear Option) + +This completely removes the PostgreSQL database and recreates it from scratch. + +### Using the Reset Script + +```bash +# Run the PostgreSQL reset script +./scripts/reset-postgres.sh +``` + +This will: +1. Stop containers +2. Remove the PostgreSQL volume (deletes ALL data) +3. Restart PostgreSQL with a fresh database +4. Wait for PostgreSQL to be ready + +**⚠️ Warning:** This deletes ALL data including: +- All cached CMDB objects +- All relations +- All attribute values +- Schema discovery cache +- Sync metadata + +### Manual Volume Reset + +```bash +# Step 1: Stop containers +docker-compose down + +# Step 2: Remove PostgreSQL volume +docker volume ls | grep postgres +docker volume rm cmdb-insight_postgres_data + +# Step 3: Start PostgreSQL again +docker-compose up -d postgres + +# Step 4: Wait for PostgreSQL to be ready +docker-compose exec postgres pg_isready -U cmdb +``` + +### After Volume Reset + +After resetting the volume, you need to: + +1. **Start the backend** - The schema will be created automatically: + ```bash + docker-compose up -d backend + ``` + +2. **Check logs** to verify schema creation: + ```bash + docker-compose logs -f backend + ``` + + You should see: + - `NormalizedCacheStore: Database schema initialized` + - `SchemaDiscovery: Schema discovery complete` + +3. **Trigger a full sync** to rebuild data: + - Via UI: Settings → Cache Management → Full Sync + - Via API: `POST /api/cache/sync` + - Or use the reset script: `./scripts/reset-and-rebuild.sh` + +## Option 3: Manual SQL Reset + +If you want to clear data but keep the database structure: + +### For PostgreSQL + +```bash +# Connect to database +docker-compose exec postgres psql -U cmdb -d cmdb_cache + +# Clear all data (keeps schema) +TRUNCATE TABLE attribute_values CASCADE; +TRUNCATE TABLE object_relations CASCADE; +TRUNCATE TABLE objects CASCADE; +TRUNCATE TABLE sync_metadata CASCADE; +TRUNCATE TABLE schema_cache CASCADE; +TRUNCATE TABLE schema_mappings CASCADE; + +# Exit +\q +``` + +Then trigger a full sync: +```bash +curl -X POST "http://localhost:3001/api/cache/sync" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### For SQLite (if using SQLite) + +```bash +# Connect to backend container +docker-compose exec backend sh + +# Clear all data +sqlite3 /app/data/cmdb-cache.db < console.log('OK')).catch(e => console.error(e)); +" +``` + +## Complete Reset Workflow + +For a complete "green field" reset: + +```bash +# 1. Stop everything +docker-compose down + +# 2. Remove PostgreSQL volume (nuclear option) +docker volume rm cmdb-insight_postgres_data + +# 3. Start PostgreSQL +docker-compose up -d postgres + +# 4. Wait for PostgreSQL +sleep 5 +docker-compose exec postgres pg_isready -U cmdb + +# 5. Start backend (creates schema automatically) +docker-compose up -d backend + +# 6. Wait for backend to initialize +sleep 10 +docker-compose logs backend | grep "initialization complete" + +# 7. Clear cache and trigger sync (via script) +./scripts/reset-and-rebuild.sh + +# OR manually via API +curl -X DELETE "http://localhost:3001/api/cache/clear" \ + -H "Authorization: Bearer YOUR_TOKEN" +curl -X POST "http://localhost:3001/api/cache/sync" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +## Quick Reference + +| Method | Speed | Data Loss | Schema Loss | Recommended For | +|--------|-------|-----------|-------------|-----------------| +| API Clear + Sync | Fast | Cache only | No | Regular resets | +| Volume Reset | Medium | Everything | Yes | Complete rebuild | +| SQL TRUNCATE | Fast | Cache only | No | Quick clear | + +## Related Documentation + +- [Local PostgreSQL Reset](./LOCAL-POSTGRES-RESET.md) - Detailed PostgreSQL reset guide +- [Local Development Setup](./LOCAL-DEVELOPMENT-SETUP.md) - Initial setup guide +- [Database Schema](./DATABASE-DRIVEN-SCHEMA-IMPLEMENTATION-PLAN.md) - Schema documentation diff --git a/docs/DATABASE-TABLES-AUDIT.md b/docs/DATABASE-TABLES-AUDIT.md new file mode 100644 index 0000000..e0a0778 --- /dev/null +++ b/docs/DATABASE-TABLES-AUDIT.md @@ -0,0 +1,136 @@ +# Database Tables Audit + +**Date:** 2026-01-09 +**Purpose:** Verify all database tables are actually being used and clean up unused ones. + +## Summary + +✅ **All active tables are in use** +❌ **Removed:** `cached_objects` (legacy, replaced by normalized schema) + +## Table Usage Status + +### ✅ Active Tables (Normalized Schema) + +These tables are part of the current normalized schema and are actively used: + +1. **`schemas`** - ✅ USED + - Stores Jira Assets schema metadata + - Used by: `SchemaRepository`, `schemaDiscoveryService`, `schemaConfigurationService` + +2. **`object_types`** - ✅ USED + - Stores discovered object types from Jira schema + - Used by: `SchemaRepository`, `schemaDiscoveryService`, `schemaCacheService`, `schemaConfigurationService` + +3. **`attributes`** - ✅ USED + - Stores discovered attributes per object type + - Used by: `SchemaRepository`, `schemaDiscoveryService`, `schemaCacheService`, `queryBuilder` + +4. **`objects`** - ✅ USED + - Stores minimal object metadata + - Used by: `normalizedCacheStore`, `ObjectCacheRepository`, `QueryService`, `dataIntegrityService` + +5. **`attribute_values`** - ✅ USED + - EAV pattern for storing all attribute values + - Used by: `normalizedCacheStore`, `ObjectCacheRepository`, `QueryService`, `queryBuilder`, `dataIntegrityService` + +6. **`object_relations`** - ✅ USED + - Stores relationships between objects + - Used by: `normalizedCacheStore`, `ObjectCacheRepository`, `QueryService`, `dataIntegrityService`, `DebugController` + +7. **`sync_metadata`** - ✅ USED + - Tracks sync state + - Used by: `normalizedCacheStore`, `cacheStore.old.ts` (legacy) + +8. **`schema_mappings`** - ⚠️ DEPRECATED but still in use + - Maps object types to schema IDs + - **Status:** Marked as DEPRECATED in schema comments, but still actively used by `schemaMappingService` + - **Note:** According to refactor plan, this should be removed after consolidation + - Used by: `schemaMappingService`, `jiraAssetsClient`, `jiraAssets`, `dataValidation` routes + +### ✅ Active Tables (Classification Database) + +9. **`classification_history`** - ✅ USED + - Stores AI classification history + - Used by: `databaseService`, `classifications` routes, `dashboard` routes + +10. **`session_state`** - ✅ USED + - Stores session state for classification workflow + - Used by: `databaseService` + +### ✅ Active Tables (Authentication & Authorization) + +11. **`users`** - ✅ USED + - User accounts + - Used by: `userService`, `authService`, `auth` routes + +12. **`roles`** - ✅ USED + - Role definitions + - Used by: `roleService`, `roles` routes + +13. **`permissions`** - ✅ USED + - Permission definitions + - Used by: `roleService`, `roles` routes + +14. **`role_permissions`** - ✅ USED + - Junction table: roles ↔ permissions + - Used by: `roleService` + +15. **`user_roles`** - ✅ USED + - Junction table: users ↔ roles + - Used by: `roleService`, `userService` + +16. **`user_settings`** - ✅ USED + - Per-user settings (PAT, AI keys, etc.) + - Used by: `userSettingsService` + +17. **`sessions`** - ✅ USED + - User sessions (OAuth and local auth) + - Used by: `authService` + +18. **`email_tokens`** - ✅ USED + - Email verification and password reset tokens + - Used by: `userService` + +## ❌ Removed Tables + +### `cached_objects` - REMOVED (Legacy) + +**Status:** ❌ Removed from schema generation +**Reason:** Replaced by normalized schema (`objects` + `attribute_values` tables) + +**Previous Usage:** +- Only used by deprecated `cacheStore.old.ts` +- Old schema stored all object data as JSON in a single table +- New normalized schema uses EAV pattern for better querying and flexibility + +**Cleanup Actions:** +- ✅ Removed from `generate-schema.ts` script +- ✅ Removed from `backend/src/generated/db-schema.sql` +- ✅ Removed from `backend/src/generated/db-schema-postgres.sql` +- ✅ Added comment in migration script explaining it's only for legacy data migration + +**Migration Note:** +- Migration script (`migrate-sqlite-to-postgres.ts`) still references `cached_objects` for migrating old data +- This is intentional - needed for one-time migration from old schema to new schema + +## Notes + +### Schema Mappings Table + +The `schema_mappings` table is marked as DEPRECATED in the schema comments but is still actively used. According to the refactor plan (`docs/REFACTOR-PHASE-2B-3-STATUS.md`), this table should be removed after consolidation of schema services. For now, it remains in use and should not be deleted. + +### Legacy Files + +The following files still reference old schema but are deprecated: +- `backend/src/services/cacheStore.old.ts` - Old cache store implementation +- `backend/src/generated/db-schema.sql` - Legacy schema (now cleaned up) +- `backend/src/generated/db-schema-postgres.sql` - Legacy schema (now cleaned up) + +## Conclusion + +✅ **All 18 active tables are in use** +✅ **1 unused table (`cached_objects`) has been removed from schema generation** +⚠️ **1 table (`schema_mappings`) is marked deprecated but still in use - keep for now** + +The database schema is now clean with only actively used tables defined in the schema generation scripts. diff --git a/docs/DEPLOYMENT-ADVICE.md b/docs/DEPLOYMENT-ADVICE.md index 582b85c..a7a68d0 100644 --- a/docs/DEPLOYMENT-ADVICE.md +++ b/docs/DEPLOYMENT-ADVICE.md @@ -1,4 +1,4 @@ -# Deployment Advies - Zuyderland CMDB GUI 🎯 +# Deployment Advies - CMDB Insight 🎯 **Datum:** {{ vandaag }} **Aanbeveling:** Azure App Service (Basic Tier) @@ -119,14 +119,14 @@ az webapp create \ --name cmdb-backend-prod \ --resource-group rg-cmdb-gui-prod \ --plan plan-cmdb-gui-prod \ - --deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest + --deployment-container-image-name zdlas.azurecr.io/cmdb-insight/backend:latest # Frontend Web App az webapp create \ --name cmdb-frontend-prod \ --resource-group rg-cmdb-gui-prod \ --plan plan-cmdb-gui-prod \ - --deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest + --deployment-container-image-name zdlas.azurecr.io/cmdb-insight/frontend:latest ``` --- @@ -186,14 +186,14 @@ az role assignment create \ az webapp config container set \ --name cmdb-backend-prod \ --resource-group rg-cmdb-gui-prod \ - --docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest \ + --docker-custom-image-name zdlas.azurecr.io/cmdb-insight/backend:latest \ --docker-registry-server-url https://zdlas.azurecr.io # Frontend az webapp config container set \ --name cmdb-frontend-prod \ --resource-group rg-cmdb-gui-prod \ - --docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest \ + --docker-custom-image-name zdlas.azurecr.io/cmdb-insight/frontend:latest \ --docker-registry-server-url https://zdlas.azurecr.io ``` diff --git a/docs/DEPLOYMENT-NEXT-STEPS.md b/docs/DEPLOYMENT-NEXT-STEPS.md index 9541df2..c3108cb 100644 --- a/docs/DEPLOYMENT-NEXT-STEPS.md +++ b/docs/DEPLOYMENT-NEXT-STEPS.md @@ -6,8 +6,8 @@ Je Docker images zijn succesvol gebouwd en gepusht naar Azure Container Registry - ✅ Azure Container Registry (ACR): `zdlas.azurecr.io` - ✅ Docker images gebouwd en gepusht: - - `zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest` - - `zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest` + - `zdlas.azurecr.io/cmdb-insight/backend:latest` + - `zdlas.azurecr.io/cmdb-insight/frontend:latest` - ✅ Azure DevOps Pipeline: Automatische builds bij push naar `main` - ✅ Docker Compose configuratie: `docker-compose.prod.acr.yml` @@ -112,19 +112,19 @@ az acr login --name zdlas az acr repository list --name zdlas --output table # List tags voor backend -az acr repository show-tags --name zdlas --repository zuyderland-cmdb-gui/backend --output table +az acr repository show-tags --name zdlas --repository cmdb-insight/backend --output table # List tags voor frontend -az acr repository show-tags --name zdlas --repository zuyderland-cmdb-gui/frontend --output table +az acr repository show-tags --name zdlas --repository cmdb-insight/frontend --output table ``` **Verwachte output:** ``` REPOSITORY TAG CREATED -zuyderland-cmdb-gui/backend latest ... -zuyderland-cmdb-gui/backend 88764 ... -zuyderland-cmdb-gui/frontend latest ... -zuyderland-cmdb-gui/frontend 88764 ... +cmdb-insight/backend latest ... +cmdb-insight/backend 88764 ... +cmdb-insight/frontend latest ... +cmdb-insight/frontend 88764 ... ``` --- @@ -136,9 +136,9 @@ zuyderland-cmdb-gui/frontend 88764 ... ```yaml services: backend: - image: zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest + image: zdlas.azurecr.io/cmdb-insight/backend:latest frontend: - image: zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest + image: zdlas.azurecr.io/cmdb-insight/frontend:latest ``` **Let op:** De huidige configuratie gebruikt `zuyderlandcmdbacr.azurecr.io` - pas dit aan naar `zdlas.azurecr.io` als dat je ACR naam is. @@ -224,7 +224,7 @@ VITE_API_URL=https://your-backend-url.com/api 5. **Clone repository en deploy:** ```bash git clone - cd zuyderland-cmdb-gui + cd cmdb-insight # Update docker-compose.prod.acr.yml met juiste ACR naam # Maak .env.production aan diff --git a/docs/DOCKER-COMPOSE-WARNINGS.md b/docs/DOCKER-COMPOSE-WARNINGS.md new file mode 100644 index 0000000..87983c7 --- /dev/null +++ b/docs/DOCKER-COMPOSE-WARNINGS.md @@ -0,0 +1,76 @@ +# Docker Compose Warnings Oplossen + +## Warnings + +Je ziet mogelijk deze warnings: + +``` +WARN[0000] The "JIRA_PAT" variable is not set. Defaulting to a blank string. +WARN[0000] The "ANTHROPIC_API_KEY" variable is not set. Defaulting to a blank string. +WARN[0000] docker-compose.yml: the attribute `version` is obsolete +``` + +## Oplossingen + +### 1. Version Attribute (Opgelost) + +De `version: '3.8'` regel is verwijderd uit `docker-compose.yml` omdat het obsolete is in nieuwere versies van Docker Compose. + +### 2. Environment Variables + +De warnings over ontbrekende environment variables zijn **normaal** als je geen `.env` bestand hebt of als deze variabelen niet gezet zijn. + +**Optie A: Maak een `.env` bestand (Aanbevolen)** + +```bash +# Kopieer .env.example naar .env +cp .env.example .env + +# Edit .env en vul de waarden in +nano .env +``` + +**Optie B: Zet variabelen in je shell** + +```bash +export JIRA_HOST=https://jira.zuyderland.nl +export JIRA_PAT=your_token +export JIRA_SCHEMA_ID=your_schema_id +export ANTHROPIC_API_KEY=your_key + +docker-compose up +``` + +**Optie C: Negeer de warnings (OK voor development)** + +De warnings zijn **niet kritisch**. De applicatie werkt ook zonder deze variabelen (je kunt dan alleen geen Jira sync doen of AI features gebruiken). + +## .env Bestand Voorbeeld + +```env +# Jira Assets +JIRA_HOST=https://jira.zuyderland.nl +JIRA_PAT=your_personal_access_token +JIRA_SCHEMA_ID=your_schema_id + +# AI (optioneel) +ANTHROPIC_API_KEY=your_anthropic_key +``` + +## Verificatie + +Na het aanmaken van `.env`: + +```bash +# Check of warnings weg zijn +docker-compose config + +# Start containers +docker-compose up -d +``` + +## Notitie + +- `.env` staat in `.gitignore` - wordt niet gecommit +- Gebruik `.env.example` als template +- Voor productie: gebruik Azure App Service environment variables of Key Vault diff --git a/docs/GITEA-DOCKER-REGISTRY.md b/docs/GITEA-DOCKER-REGISTRY.md index c04bbf0..aabeb04 100644 --- a/docs/GITEA-DOCKER-REGISTRY.md +++ b/docs/GITEA-DOCKER-REGISTRY.md @@ -1,6 +1,6 @@ # Gitea Docker Container Registry - Deployment Guide -Deze guide beschrijft hoe je Gitea gebruikt als Docker Container Registry voor het deployen van de Zuyderland CMDB GUI applicatie in productie. +Deze guide beschrijft hoe je Gitea gebruikt als Docker Container Registry voor het deployen van de CMDB Insight applicatie in productie. ## 📋 Inhoudsopgave diff --git a/docs/GREEN-FIELD-DEPLOYMENT-GUIDE.md b/docs/GREEN-FIELD-DEPLOYMENT-GUIDE.md new file mode 100644 index 0000000..6497e30 --- /dev/null +++ b/docs/GREEN-FIELD-DEPLOYMENT-GUIDE.md @@ -0,0 +1,593 @@ +# Green Field Deployment Guide + +## Overzicht + +Deze guide beschrijft hoe je de applicatie volledig opnieuw deployt met de nieuwe genormaliseerde database structuur. Aangezien het een green field deployment is, kunnen we alles schoon opzetten. + +--- + +## Stap 1: Database Setup + +### Option A: PostgreSQL (Aanbevolen voor productie) + +#### 1.1 Azure Database for PostgreSQL + +```bash +# Via Azure Portal of CLI +az postgres flexible-server create \ + --resource-group \ + --name \ + --location \ + --admin-user \ + --admin-password \ + --sku-name Standard_B1ms \ + --tier Burstable \ + --version 14 +``` + +#### 1.2 Database Aanmaken + +```sql +-- Connect to PostgreSQL +CREATE DATABASE cmdb_cache; +CREATE DATABASE cmdb_classifications; + +-- Create user (optional, can use admin user) +CREATE USER cmdb_user WITH PASSWORD 'secure_password'; +GRANT ALL PRIVILEGES ON DATABASE cmdb_cache TO cmdb_user; +GRANT ALL PRIVILEGES ON DATABASE cmdb_classifications TO cmdb_user; +``` + +#### 1.3 Connection String + +```env +DATABASE_TYPE=postgres +DATABASE_URL=postgresql://cmdb_user:secure_password@.postgres.database.azure.com:5432/cmdb_cache?sslmode=require +CLASSIFICATIONS_DATABASE_URL=postgresql://cmdb_user:secure_password@.postgres.database.azure.com:5432/cmdb_classifications?sslmode=require +``` + +### Option B: SQLite (Voor development/testing) + +```env +DATABASE_TYPE=sqlite +# Database files worden automatisch aangemaakt in backend/data/ +``` + +--- + +## Stap 2: Environment Variables + +### 2.1 Basis Configuratie + +Maak `.env` bestand in project root: + +```env +# Server +PORT=3001 +NODE_ENV=production +FRONTEND_URL=https://your-domain.com + +# Database (zie Stap 1) +DATABASE_TYPE=postgres +DATABASE_URL=postgresql://... + +# Jira Assets +JIRA_HOST=https://jira.zuyderland.nl +JIRA_SCHEMA_ID= +JIRA_SERVICE_ACCOUNT_TOKEN= + +# Jira Authentication Method +JIRA_AUTH_METHOD=oauth + +# OAuth Configuration (als JIRA_AUTH_METHOD=oauth) +JIRA_OAUTH_CLIENT_ID= +JIRA_OAUTH_CLIENT_SECRET= +JIRA_OAUTH_CALLBACK_URL=https://your-domain.com/api/auth/callback +JIRA_OAUTH_SCOPES=READ WRITE + +# Session +SESSION_SECRET= + +# AI (configureer per gebruiker in profile settings) +# ANTHROPIC_API_KEY, OPENAI_API_KEY, TAVILY_API_KEY worden per gebruiker ingesteld +``` + +### 2.2 Session Secret Genereren + +```bash +# Generate secure random string +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +--- + +## Stap 3: Schema Discovery + +### 3.1 Schema Genereren + +```bash +cd backend +npm run generate-schema +``` + +Dit: +- Haalt schema op van Jira Assets API +- Genereert `backend/src/generated/jira-schema.ts` +- Genereert `backend/src/generated/jira-types.ts` + +### 3.2 Schema Populeren in Database + +Bij eerste start van de applicatie: +- `schemaDiscoveryService` leest `OBJECT_TYPES` uit generated schema +- Populeert `object_types` en `attributes` tabellen +- Gebeurt automatisch bij initialisatie + +--- + +## Stap 4: Application Build + +### 4.1 Dependencies Installeren + +```bash +# Root +npm install + +# Backend +cd backend +npm install + +# Frontend +cd frontend +npm install +``` + +### 4.2 Build + +```bash +# Backend +cd backend +npm run build + +# Frontend +cd frontend +npm run build +``` + +--- + +## Stap 5: Database Initialisatie + +### 5.1 Automatische Initialisatie + +Bij eerste start: +1. Normalized schema wordt aangemaakt +2. Schema discovery wordt uitgevoerd +3. Tabellen worden gevuld met object types en attributes + +### 5.2 Handmatige Verificatie (Optioneel) + +```sql +-- Check object types +SELECT * FROM object_types ORDER BY sync_priority; + +-- Check attributes +SELECT COUNT(*) FROM attributes; + +-- Check per type +SELECT object_type_name, COUNT(*) as attr_count +FROM attributes +GROUP BY object_type_name; +``` + +--- + +## Stap 6: Data Sync + +### 6.1 Eerste Sync + +```bash +# Via API (na deployment) +curl -X POST https://your-domain.com/api/cache/sync \ + -H "Authorization: Bearer " +``` + +Of via applicatie: +- Ga naar Settings → Cache Management +- Klik "Full Sync" + +### 6.2 Sync Status Checken + +```bash +curl https://your-domain.com/api/cache/status \ + -H "Authorization: Bearer " +``` + +--- + +## Stap 7: Docker Deployment + +### 7.1 Build Images + +```bash +# Backend +docker build -t cmdb-backend:latest -f backend/Dockerfile . + +# Frontend +docker build -t cmdb-frontend:latest -f frontend/Dockerfile . +``` + +### 7.2 Docker Compose (Production) + +```yaml +# docker-compose.prod.yml +version: '3.8' + +services: + backend: + image: cmdb-backend:latest + environment: + - DATABASE_TYPE=postgres + - DATABASE_URL=${DATABASE_URL} + - JIRA_HOST=${JIRA_HOST} + - JIRA_SCHEMA_ID=${JIRA_SCHEMA_ID} + - JIRA_SERVICE_ACCOUNT_TOKEN=${JIRA_SERVICE_ACCOUNT_TOKEN} + - SESSION_SECRET=${SESSION_SECRET} + ports: + - "3001:3001" + + frontend: + image: cmdb-frontend:latest + ports: + - "80:80" + depends_on: + - backend + + nginx: + image: nginx:alpine + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + ports: + - "443:443" + depends_on: + - frontend + - backend +``` + +### 7.3 Starten + +```bash +docker-compose -f docker-compose.prod.yml up -d +``` + +--- + +## Stap 8: Azure App Service Deployment + +### 8.1 Azure Container Registry + +```bash +# Login +az acr login --name + +# Tag images +docker tag cmdb-backend:latest .azurecr.io/cmdb-backend:latest +docker tag cmdb-frontend:latest .azurecr.io/cmdb-frontend:latest + +# Push +docker push .azurecr.io/cmdb-backend:latest +docker push .azurecr.io/cmdb-frontend:latest +``` + +### 8.2 App Service Configuratie + +**Backend App Service:** +- Container: `.azurecr.io/cmdb-backend:latest` +- Environment variables: Alle `.env` variabelen +- Port: 3001 + +**Frontend App Service:** +- Container: `.azurecr.io/cmdb-frontend:latest` +- Environment variables: `VITE_API_URL=https://backend-app.azurewebsites.net` + +### 8.3 Deployment via Azure DevOps + +Zie `azure-pipelines.yml` voor CI/CD pipeline. + +--- + +## Stap 9: Verificatie + +### 9.1 Health Checks + +```bash +# Backend health +curl https://backend-app.azurewebsites.net/health + +# Frontend +curl https://frontend-app.azurewebsites.net +``` + +### 9.2 Database Verificatie + +```sql +-- Check object count +SELECT object_type_name, COUNT(*) as count +FROM objects +GROUP BY object_type_name; + +-- Check attribute values +SELECT COUNT(*) FROM attribute_values; + +-- Check relations +SELECT COUNT(*) FROM object_relations; +``` + +### 9.3 Functionaliteit Testen + +1. **Login** - Test authenticatie +2. **Dashboard** - Check of data wordt getoond +3. **Application List** - Test filters +4. **Application Detail** - Test edit functionaliteit +5. **Sync** - Test manual sync + +--- + +## Stap 10: Monitoring & Maintenance + +### 10.1 Logs + +```bash +# Azure App Service logs +az webapp log tail --name --resource-group + +# Docker logs +docker-compose logs -f backend +``` + +### 10.2 Database Monitoring + +```sql +-- Database size +SELECT pg_database_size('cmdb_cache'); + +-- Table sizes +SELECT + schemaname, + tablename, + pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size +FROM pg_tables +WHERE schemaname = 'public' +ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC; + +-- Index usage +SELECT + schemaname, + tablename, + indexname, + idx_scan as index_scans +FROM pg_stat_user_indexes +ORDER BY idx_scan DESC; +``` + +### 10.3 Performance Monitoring + +- Monitor query performance +- Check sync duration +- Monitor database connections +- Check memory usage + +--- + +## Troubleshooting + +### Database Connection Issues + +```bash +# Test connection +psql $DATABASE_URL -c "SELECT 1" + +# Check firewall rules (Azure) +az postgres flexible-server firewall-rule list \ + --resource-group \ + --name +``` + +### Schema Discovery Fails + +```bash +# Check Jira connection +curl -H "Authorization: Bearer $JIRA_SERVICE_ACCOUNT_TOKEN" \ + $JIRA_HOST/rest/insight/1.0/objectschema/list + +# Regenerate schema +cd backend +npm run generate-schema +``` + +### Sync Issues + +```bash +# Check sync status +curl https://your-domain.com/api/cache/status + +# Manual sync for specific type +curl -X POST https://your-domain.com/api/cache/sync/ApplicationComponent +``` + +--- + +## Rollback Plan + +Als er problemen zijn: + +1. **Stop applicatie** +2. **Revert code** (git) +3. **Herstart applicatie** + +Aangezien het green field is, is er geen data migration nodig voor rollback. + +--- + +## Post-Deployment Checklist + +- [ ] Database verbinding werkt +- [ ] Schema discovery succesvol +- [ ] Eerste sync voltooid +- [ ] Alle object types gesynct +- [ ] Queries werken correct +- [ ] Filters werken +- [ ] Edit functionaliteit werkt +- [ ] Authentication werkt +- [ ] Logs zijn zichtbaar +- [ ] Monitoring is ingesteld + +--- + +## Performance Tips + +1. **Database Indexes** - Zijn automatisch aangemaakt +2. **Connection Pooling** - PostgreSQL adapter gebruikt pool (max 20) +3. **Query Optimization** - Gebruik `queryWithFilters()` voor gefilterde queries +4. **Sync Frequency** - Incremental sync elke 30 seconden (configureerbaar) + +--- + +## Security Checklist + +- [ ] `SESSION_SECRET` is sterk en uniek +- [ ] Database credentials zijn secure +- [ ] HTTPS is ingeschakeld +- [ ] CORS is correct geconfigureerd +- [ ] OAuth callback URL is correct +- [ ] Environment variables zijn niet gecommit + +--- + +## Extra Tips & Best Practices + +### Database Performance + +1. **Connection Pooling** + - PostgreSQL adapter gebruikt automatisch connection pooling (max 20) + - Monitor pool usage in production + +2. **Query Optimization** + - Gebruik `queryWithFilters()` voor gefilterde queries (veel sneller) + - Indexes zijn automatisch aangemaakt + - Monitor slow queries + +3. **Sync Performance** + - Batch size: 50 objects per batch (configureerbaar via `JIRA_API_BATCH_SIZE`) + - Incremental sync: elke 30 seconden (configureerbaar via `SYNC_INCREMENTAL_INTERVAL_MS`) + +### Monitoring + +1. **Application Logs** + - Check voor schema discovery errors + - Monitor sync duration + - Check query performance + +2. **Database Metrics** + - Table sizes groeien + - Index usage + - Connection pool usage + +3. **Jira API** + - Monitor rate limiting + - Check API response times + - Monitor sync success rate + +### Backup Strategy + +1. **Database Backups** + - Azure PostgreSQL: automatische dagelijkse backups + - SQLite: maak periodieke copies van `.db` files + +2. **Configuration Backup** + - Backup `.env` file (securely!) + - Document alle environment variables + +### Scaling Considerations + +1. **Database** + - PostgreSQL kan schalen (vertical scaling) + - Overweeg read replicas voor grote datasets + +2. **Application** + - Stateless design - kan horizontaal schalen + - Session storage in database (scalable) + +3. **Cache** + - Normalized structure is efficient + - Indexes zorgen voor goede performance + +### Troubleshooting Common Issues + +#### Issue: Schema Discovery Fails + +**Symptoom:** Error bij startup, geen object types in database + +**Oplossing:** +```bash +# Check Jira connection +curl -H "Authorization: Bearer $JIRA_SERVICE_ACCOUNT_TOKEN" \ + $JIRA_HOST/rest/insight/1.0/objectschema/list + +# Regenerate schema +cd backend +npm run generate-schema + +# Restart application +``` + +#### Issue: Sync is Slow + +**Symptoom:** Full sync duurt lang + +**Oplossing:** +- Check Jira API response times +- Verhoog batch size (maar niet te veel - rate limiting) +- Check database connection pool + +#### Issue: Queries zijn Langzaam + +**Symptoom:** Filters werken traag + +**Oplossing:** +- Check of indexes bestaan: `\d+ attribute_values` in PostgreSQL +- Gebruik `queryWithFilters()` in plaats van JavaScript filtering +- Check query execution plan + +#### Issue: Memory Usage Hoog + +**Symptoom:** Applicatie gebruikt veel geheugen + +**Oplossing:** +- Normalized storage gebruikt minder geheugen dan JSONB +- Check of oude cacheStore nog ergens gebruikt wordt +- Monitor object reconstruction (kan N+1 queries veroorzaken) + +### Development vs Production + +**Development:** +- SQLite is prima voor testing +- Lokale database in `backend/data/` +- Geen SSL nodig + +**Production:** +- PostgreSQL is aanbevolen +- Azure Database for PostgreSQL +- SSL vereist +- Connection pooling +- Monitoring ingeschakeld + +### Migration van Development naar Production + +1. **Schema is hetzelfde** - geen migratie nodig +2. **Data sync** - eerste sync haalt alles op van Jira +3. **Environment variables** - update voor productie +4. **OAuth callback URL** - update naar productie domain + +--- + +**End of Guide** diff --git a/docs/JIRA-ASSETS-SYNC-REFACTOR-PLAN-2025-01-21 b/docs/JIRA-ASSETS-SYNC-REFACTOR-PLAN-2025-01-21 new file mode 100644 index 0000000..7cb79f7 --- /dev/null +++ b/docs/JIRA-ASSETS-SYNC-REFACTOR-PLAN-2025-01-21 @@ -0,0 +1,634 @@ +# Cursor AI Prompt: Jira Assets Schema Synchronization + +## Context + +This application syncs Jira Assets (Data Center) data to a local database with a generic structure. Your task is to review, implement, and/or modify the schema synchronization feature that fetches the complete Jira Assets configuration structure. + +## Objective + +Implement or verify the schema sync functionality that extracts the complete Jira Assets schema structure using the REST API. This includes: +- Object Schemas +- Object Types (with hierarchy) +- Object Type Attributes (field definitions) + +**Note:** This task focuses on syncing the *structure/configuration* only, not the actual object data. + +--- + +## API Reference + +### Base URL +``` +{JIRA_BASE_URL}/rest/assets/1.0 +``` + +### Authentication +- HTTP Basic Authentication (username + password/API token) +- All requests require `Accept: application/json` header + +--- + +## Required API Endpoints & Response Structures + +### 1. List All Schemas +``` +GET /rest/assets/1.0/objectschema/list +``` + +**Response Structure:** +```json +{ + "objectschemas": [ + { + "id": 1, + "name": "IT Assets", + "objectSchemaKey": "IT", + "status": "Ok", + "description": "IT Asset Management Schema", + "created": "2024-01-15T10:30:00.000Z", + "updated": "2024-01-20T14:45:00.000Z", + "objectCount": 1500, + "objectTypeCount": 25 + } + ] +} +``` + +**Fields to Store:** +| Field | Type | Description | +|-------|------|-------------| +| id | integer | Primary identifier | +| name | string | Schema name | +| objectSchemaKey | string | Unique key (e.g., "IT") | +| status | string | Schema status | +| description | string | Optional description | +| created | datetime | Creation timestamp | +| updated | datetime | Last modification | +| objectCount | integer | Total objects in schema | +| objectTypeCount | integer | Total object types | + +--- + +### 2. Get Schema Details +``` +GET /rest/assets/1.0/objectschema/:id +``` + +**Response Structure:** +```json +{ + "id": 1, + "name": "IT Assets", + "objectSchemaKey": "IT", + "status": "Ok", + "description": "IT Asset Management Schema", + "created": "2024-01-15T10:30:00.000Z", + "updated": "2024-01-20T14:45:00.000Z", + "objectCount": 1500, + "objectTypeCount": 25 +} +``` + +--- + +### 3. Get Object Types (Flat List) +``` +GET /rest/assets/1.0/objectschema/:id/objecttypes/flat +``` + +**Response Structure:** +```json +[ + { + "id": 10, + "name": "Hardware", + "type": 0, + "description": "Physical hardware assets", + "icon": { + "id": 1, + "name": "Computer", + "url16": "/rest/assets/1.0/icon/1/16", + "url48": "/rest/assets/1.0/icon/1/48" + }, + "position": 0, + "created": "2024-01-15T10:30:00.000Z", + "updated": "2024-01-20T14:45:00.000Z", + "objectCount": 500, + "parentObjectTypeId": null, + "objectSchemaId": 1, + "inherited": false, + "abstractObjectType": false + }, + { + "id": 11, + "name": "Computer", + "type": 0, + "description": "Desktop and laptop computers", + "icon": { + "id": 2, + "name": "Laptop", + "url16": "/rest/assets/1.0/icon/2/16", + "url48": "/rest/assets/1.0/icon/2/48" + }, + "position": 0, + "created": "2024-01-15T10:35:00.000Z", + "updated": "2024-01-20T14:50:00.000Z", + "objectCount": 200, + "parentObjectTypeId": 10, + "objectSchemaId": 1, + "inherited": true, + "abstractObjectType": false + } +] +``` + +**Fields to Store:** +| Field | Type | Description | +|-------|------|-------------| +| id | integer | Primary identifier | +| name | string | Object type name | +| type | integer | Type classification (0=normal) | +| description | string | Optional description | +| icon | object | Icon details (id, name, url16, url48) | +| position | integer | Display position in hierarchy | +| created | datetime | Creation timestamp | +| updated | datetime | Last modification | +| objectCount | integer | Number of objects of this type | +| parentObjectTypeId | integer/null | Parent type ID (null if root) | +| objectSchemaId | integer | Parent schema ID | +| inherited | boolean | Whether attributes are inherited | +| abstractObjectType | boolean | Whether type is abstract (no direct objects) | + +--- + +### 4. Get Object Type Details +``` +GET /rest/assets/1.0/objecttype/:id +``` + +**Response Structure:** Same as individual item in the flat list above. + +--- + +### 5. Get Object Type Attributes +``` +GET /rest/assets/1.0/objecttype/:id/attributes +``` + +**Response Structure:** +```json +[ + { + "id": 100, + "objectType": { + "id": 11, + "name": "Computer" + }, + "name": "Name", + "label": true, + "type": 0, + "description": "Asset name/label", + "defaultType": { + "id": 0, + "name": "Text" + }, + "typeValue": null, + "typeValueMulti": [], + "additionalValue": null, + "referenceType": null, + "referenceObjectTypeId": null, + "referenceObjectType": null, + "editable": true, + "system": true, + "sortable": true, + "summable": false, + "indexed": true, + "minimumCardinality": 1, + "maximumCardinality": 1, + "suffix": "", + "removable": false, + "hidden": false, + "includeChildObjectTypes": false, + "uniqueAttribute": false, + "regexValidation": null, + "iql": null, + "options": "", + "position": 0 + }, + { + "id": 101, + "objectType": { + "id": 11, + "name": "Computer" + }, + "name": "Serial Number", + "label": false, + "type": 0, + "description": "Device serial number", + "defaultType": { + "id": 0, + "name": "Text" + }, + "typeValue": null, + "typeValueMulti": [], + "additionalValue": null, + "referenceType": null, + "referenceObjectTypeId": null, + "referenceObjectType": null, + "editable": true, + "system": false, + "sortable": true, + "summable": false, + "indexed": true, + "minimumCardinality": 0, + "maximumCardinality": 1, + "suffix": "", + "removable": true, + "hidden": false, + "includeChildObjectTypes": false, + "uniqueAttribute": true, + "regexValidation": "^[A-Z0-9]{10,20}$", + "iql": null, + "options": "", + "position": 1 + }, + { + "id": 102, + "objectType": { + "id": 11, + "name": "Computer" + }, + "name": "Assigned User", + "label": false, + "type": 2, + "description": "User assigned to this asset", + "defaultType": null, + "typeValue": "SHOW_ON_ASSET", + "typeValueMulti": [], + "additionalValue": null, + "referenceType": null, + "referenceObjectTypeId": null, + "referenceObjectType": null, + "editable": true, + "system": false, + "sortable": true, + "summable": false, + "indexed": true, + "minimumCardinality": 0, + "maximumCardinality": 1, + "suffix": "", + "removable": true, + "hidden": false, + "includeChildObjectTypes": false, + "uniqueAttribute": false, + "regexValidation": null, + "iql": null, + "options": "", + "position": 2 + }, + { + "id": 103, + "objectType": { + "id": 11, + "name": "Computer" + }, + "name": "Location", + "label": false, + "type": 1, + "description": "Physical location of the asset", + "defaultType": null, + "typeValue": null, + "typeValueMulti": [], + "additionalValue": null, + "referenceType": { + "id": 1, + "name": "Reference", + "description": "Standard reference", + "color": "#0052CC", + "url16": null, + "removable": false, + "objectSchemaId": 1 + }, + "referenceObjectTypeId": 20, + "referenceObjectType": { + "id": 20, + "name": "Location", + "objectSchemaId": 1 + }, + "editable": true, + "system": false, + "sortable": true, + "summable": false, + "indexed": true, + "minimumCardinality": 0, + "maximumCardinality": 1, + "suffix": "", + "removable": true, + "hidden": false, + "includeChildObjectTypes": true, + "uniqueAttribute": false, + "regexValidation": null, + "iql": "objectType = Location", + "options": "", + "position": 3 + }, + { + "id": 104, + "objectType": { + "id": 11, + "name": "Computer" + }, + "name": "Status", + "label": false, + "type": 7, + "description": "Current asset status", + "defaultType": null, + "typeValue": "1", + "typeValueMulti": ["1", "2", "3"], + "additionalValue": null, + "referenceType": null, + "referenceObjectTypeId": null, + "referenceObjectType": null, + "editable": true, + "system": false, + "sortable": true, + "summable": false, + "indexed": true, + "minimumCardinality": 1, + "maximumCardinality": 1, + "suffix": "", + "removable": true, + "hidden": false, + "includeChildObjectTypes": false, + "uniqueAttribute": false, + "regexValidation": null, + "iql": null, + "options": "", + "position": 4 + } +] +``` + +**Attribute Fields to Store:** +| Field | Type | Description | +|-------|------|-------------| +| id | integer | Attribute ID | +| objectType | object | Parent object type {id, name} | +| name | string | Attribute name | +| label | boolean | Is this the label/display attribute | +| type | integer | Attribute type (see type reference below) | +| description | string | Optional description | +| defaultType | object/null | Default type info {id, name} for type=0 | +| typeValue | string/null | Type-specific configuration | +| typeValueMulti | array | Multiple type values (e.g., allowed status IDs) | +| additionalValue | string/null | Additional configuration | +| referenceType | object/null | Reference type details for type=1 | +| referenceObjectTypeId | integer/null | Target object type ID for references | +| referenceObjectType | object/null | Target object type details | +| editable | boolean | Can values be edited | +| system | boolean | Is system attribute (Name, Key, Created, Updated) | +| sortable | boolean | Can sort by this attribute | +| summable | boolean | Can sum values (numeric types) | +| indexed | boolean | Is indexed for search | +| minimumCardinality | integer | Minimum required values (0=optional, 1=required) | +| maximumCardinality | integer | Maximum values (-1=unlimited, 1=single) | +| suffix | string | Display suffix (e.g., "GB", "USD") | +| removable | boolean | Can attribute be deleted | +| hidden | boolean | Is hidden from default view | +| includeChildObjectTypes | boolean | Include child types in reference selection | +| uniqueAttribute | boolean | Must values be unique | +| regexValidation | string/null | Validation regex pattern | +| iql | string/null | IQL/AQL filter for reference selection | +| options | string | Additional options (CSV for Select type) | +| position | integer | Display order position | + +--- + +## Attribute Type Reference + +### Main Types (type field) +| Type | Name | Description | Uses defaultType | +|------|------|-------------|------------------| +| 0 | Default | Uses defaultType for specific type | Yes | +| 1 | Object | Reference to another Assets object | No | +| 2 | User | Jira user reference | No | +| 3 | Confluence | Confluence page reference | No | +| 4 | Group | Jira group reference | No | +| 5 | Version | Jira version reference | No | +| 6 | Project | Jira project reference | No | +| 7 | Status | Status type reference | No | + +### Default Types (defaultType.id when type=0) +| ID | Name | Description | +|----|------|-------------| +| 0 | Text | Single-line text | +| 1 | Integer | Whole number | +| 2 | Boolean | True/False checkbox | +| 3 | Double | Decimal number | +| 4 | Date | Date only (no time) | +| 5 | Time | Time only (no date) | +| 6 | DateTime | Date and time | +| 7 | URL | Web link | +| 8 | Email | Email address | +| 9 | Textarea | Multi-line text | +| 10 | Select | Dropdown selection (options in `options` field) | +| 11 | IP Address | IP address format | + +--- + +## Implementation Requirements + +### 1. Sync Flow + +Implement the following synchronization flow: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Schema Sync Process │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 1. GET /objectschema/list │ +│ └── Store/Update all schemas in local DB │ +│ │ +│ 2. For each schema: │ +│ ├── GET /objectschema/:id │ +│ │ └── Update schema details │ +│ │ │ +│ └── GET /objectschema/:id/objecttypes/flat │ +│ └── Store/Update all object types │ +│ │ +│ 3. For each object type: │ +│ ├── GET /objecttype/:id (optional, for latest details) │ +│ │ │ +│ └── GET /objecttype/:id/attributes │ +│ └── Store/Update all attributes │ +│ │ +│ 4. Clean up orphaned records (deleted in Jira) │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2. Database Operations + +For each entity type, implement: +- **Upsert logic**: Insert new records, update existing ones based on Jira ID +- **Soft delete or cleanup**: Handle items that exist locally but not in Jira anymore +- **Relationship mapping**: Maintain foreign key relationships (schema → object types → attributes) + +### 3. Rate Limiting + +Implement rate limiting to avoid overloading the Jira server: +- Add 100-200ms delay between API requests +- Implement exponential backoff on 429 (Too Many Requests) responses +- Maximum 3-5 concurrent requests if using parallel processing + +```typescript +// Example rate limiting implementation +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +async function fetchWithRateLimit(url: string): Promise { + await delay(150); // 150ms between requests + + const response = await fetch(url, { headers: getAuthHeaders() }); + + if (response.status === 429) { + const retryAfter = parseInt(response.headers.get('Retry-After') || '5'); + await delay(retryAfter * 1000); + return fetchWithRateLimit(url); + } + + return response.json(); +} +``` + +### 4. Error Handling + +Handle these scenarios: +- **401 Unauthorized**: Invalid credentials +- **403 Forbidden**: Insufficient permissions +- **404 Not Found**: Schema/Type deleted during sync +- **429 Too Many Requests**: Rate limited (implement backoff) +- **5xx Server Errors**: Retry with exponential backoff + +### 5. Progress Tracking + +Implement progress reporting: +- Total schemas to process +- Current schema being processed +- Total object types to process +- Current object type being processed +- Estimated time remaining (optional) + +--- + +## Code Structure Suggestions + +### Service/Repository Pattern + +``` +src/ +├── services/ +│ └── jira-assets/ +│ ├── JiraAssetsApiClient.ts # HTTP client with auth & rate limiting +│ ├── SchemaSyncService.ts # Main sync orchestration +│ ├── ObjectTypeSyncService.ts # Object type sync logic +│ └── AttributeSyncService.ts # Attribute sync logic +├── repositories/ +│ ├── SchemaRepository.ts # Schema DB operations +│ ├── ObjectTypeRepository.ts # Object type DB operations +│ └── AttributeRepository.ts # Attribute DB operations +└── models/ + ├── Schema.ts # Schema entity/model + ├── ObjectType.ts # Object type entity/model + └── ObjectTypeAttribute.ts # Attribute entity/model +``` + +### Sync Service Interface + +```typescript +interface SchemaSyncService { + /** + * Sync all schemas and their complete structure + * @returns Summary of sync operation + */ + syncAll(): Promise; + + /** + * Sync a single schema by ID + * @param schemaId - Jira schema ID + */ + syncSchema(schemaId: number): Promise; + + /** + * Get sync status/progress + */ + getProgress(): SyncProgress; +} + +interface SyncResult { + success: boolean; + schemasProcessed: number; + objectTypesProcessed: number; + attributesProcessed: number; + errors: SyncError[]; + duration: number; // milliseconds +} + +interface SyncProgress { + status: 'idle' | 'running' | 'completed' | 'failed'; + currentSchema?: string; + currentObjectType?: string; + schemasTotal: number; + schemasCompleted: number; + objectTypesTotal: number; + objectTypesCompleted: number; + startedAt?: Date; + estimatedCompletion?: Date; +} +``` + +--- + +## Validation Checklist + +After implementation, verify: + +- [ ] All schemas are fetched from `/objectschema/list` +- [ ] Schema details are updated from `/objectschema/:id` +- [ ] All object types are fetched for each schema from `/objectschema/:id/objecttypes/flat` +- [ ] Object type hierarchy (parentObjectTypeId) is preserved +- [ ] All attributes are fetched for each object type from `/objecttype/:id/attributes` +- [ ] Attribute types are correctly mapped (type + defaultType) +- [ ] Reference attributes store referenceObjectTypeId and referenceType +- [ ] Status attributes store typeValueMulti (allowed status IDs) +- [ ] Rate limiting prevents 429 errors +- [ ] Error handling covers all failure scenarios +- [ ] Sync can be resumed after failure +- [ ] Orphaned local records are handled (deleted in Jira) +- [ ] Foreign key relationships are maintained +- [ ] Timestamps (created, updated) are stored correctly + +--- + +## Testing Scenarios + +1. **Initial sync**: Empty local database, full sync from Jira +2. **Incremental sync**: Existing data, detect changes +3. **Schema added**: New schema created in Jira +4. **Schema deleted**: Schema removed from Jira +5. **Object type added**: New type in existing schema +6. **Object type moved**: Parent changed in hierarchy +7. **Attribute added/modified/removed**: Changes to type attributes +8. **Large schema**: Schema with 50+ object types, 500+ attributes +9. **Network failure**: Handle timeouts and retries +10. **Rate limiting**: Handle 429 responses gracefully + +--- + +## Notes + +- The `/objectschema/:id/objecttypes/flat` endpoint returns ALL object types in one call, which is more efficient than fetching hierarchically +- The `label` field on attributes indicates which attribute is used as the display name for objects +- System attributes (system=true) are: Name, Key, Created, Updated - these exist on all object types +- The `iql` field on reference attributes contains the filter query for selecting valid reference targets +- The `options` field on Select type attributes (type=0, defaultType.id=10) contains comma-separated options diff --git a/docs/LOCAL-DEVELOPMENT-SETUP.md b/docs/LOCAL-DEVELOPMENT-SETUP.md new file mode 100644 index 0000000..0ba5224 --- /dev/null +++ b/docs/LOCAL-DEVELOPMENT-SETUP.md @@ -0,0 +1,177 @@ +# Local Development Setup + +## PostgreSQL Only (Recommended for Local Development) + +Voor lokale development heb je alleen PostgreSQL nodig. De backend en frontend draaien lokaal op je MacBook. + +### Start PostgreSQL + +```bash +# Start alleen PostgreSQL +docker-compose -f docker-compose.dev.yml up -d + +# Check status +docker-compose -f docker-compose.dev.yml ps + +# Check logs +docker-compose -f docker-compose.dev.yml logs -f postgres +``` + +### Connection String + +```env +DATABASE_TYPE=postgres +DATABASE_URL=postgresql://cmdb:cmdb-dev@localhost:5432/cmdb_cache +``` + +Of individuele variabelen: + +```env +DATABASE_TYPE=postgres +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_NAME=cmdb_cache +DATABASE_USER=cmdb +DATABASE_PASSWORD=cmdb-dev +``` + +### Stop PostgreSQL + +```bash +docker-compose -f docker-compose.dev.yml down +``` + +### Reset Database + +```bash +# Stop en verwijder volume +docker-compose -f docker-compose.dev.yml down -v + +# Start opnieuw +docker-compose -f docker-compose.dev.yml up -d +``` + +### Connect to Database + +```bash +# Via psql +docker-compose -f docker-compose.dev.yml exec postgres psql -U cmdb -d cmdb_cache + +# Of direct +psql postgresql://cmdb:cmdb-dev@localhost:5432/cmdb_cache +``` + +### Useful Commands + +```bash +# List databases +docker-compose -f docker-compose.dev.yml exec postgres psql -U cmdb -c "\l" + +# List tables +docker-compose -f docker-compose.dev.yml exec postgres psql -U cmdb -d cmdb_cache -c "\dt" + +# Check database size +docker-compose -f docker-compose.dev.yml exec postgres psql -U cmdb -d cmdb_cache -c " + SELECT pg_size_pretty(pg_database_size('cmdb_cache')) as size; +" + +# Count objects +docker-compose -f docker-compose.dev.yml exec postgres psql -U cmdb -d cmdb_cache -c " + SELECT object_type_name, COUNT(*) + FROM objects + GROUP BY object_type_name; +" +``` + +## Full Stack (Alternative) + +Als je de hele stack in Docker wilt draaien: + +```bash +docker-compose up -d +``` + +Dit start: +- PostgreSQL +- Backend (in Docker) +- Frontend (in Docker) + +## Backend Development + +Met alleen PostgreSQL draaiend: + +```bash +# In backend directory +cd backend +npm install +npm run dev +``` + +Backend draait op `http://localhost:3001` + +## Frontend Development + +```bash +# In frontend directory +cd frontend +npm install +npm run dev +``` + +Frontend draait op `http://localhost:5173` + +## Environment Variables + +Maak een `.env` bestand in de root: + +```env +# Database (voor backend) +DATABASE_TYPE=postgres +DATABASE_URL=postgresql://cmdb:cmdb-dev@localhost:5432/cmdb_cache + +# Jira (optioneel) +JIRA_HOST=https://jira.zuyderland.nl +JIRA_PAT=your_token +JIRA_SCHEMA_ID=your_schema_id + +# AI (optioneel) +ANTHROPIC_API_KEY=your_key +``` + +## Troubleshooting + +### Port Already in Use + +Als poort 5432 al in gebruik is: + +```yaml +# In docker-compose.dev.yml, wijzig: +ports: + - "5433:5432" # Gebruik 5433 lokaal +``` + +En update je `.env`: +```env +DATABASE_PORT=5433 +``` + +### Connection Refused + +```bash +# Check of container draait +docker ps | grep postgres + +# Check logs +docker-compose -f docker-compose.dev.yml logs postgres + +# Test connection +docker-compose -f docker-compose.dev.yml exec postgres pg_isready -U cmdb +``` + +### Database Not Found + +De database wordt automatisch aangemaakt bij eerste start van de backend. Of maak handmatig: + +```bash +docker-compose -f docker-compose.dev.yml exec postgres psql -U cmdb -c "CREATE DATABASE cmdb_cache;" +``` diff --git a/docs/LOCAL-POSTGRES-RESET.md b/docs/LOCAL-POSTGRES-RESET.md new file mode 100644 index 0000000..deedfb2 --- /dev/null +++ b/docs/LOCAL-POSTGRES-RESET.md @@ -0,0 +1,185 @@ +# PostgreSQL Database Reset (Lokaal) + +## Quick Reset + +Om de PostgreSQL database volledig te resetten (green field simulatie): + +```bash +# Option 1: Gebruik het reset script +./scripts/reset-postgres.sh +``` + +## Handmatige Reset + +Als je het handmatig wilt doen: + +### Stap 1: Stop Containers + +```bash +docker-compose down +``` + +### Stap 2: Verwijder PostgreSQL Volume + +```bash +# Check volumes +docker volume ls | grep postgres + +# Verwijder volume (dit verwijdert ALLE data!) +docker volume rm cmdb-insight_postgres_data +``` + +**Let op:** Dit verwijdert alle data permanent! + +### Stap 3: Start Containers Opnieuw + +```bash +docker-compose up -d postgres +``` + +### Stap 4: Wacht tot PostgreSQL Ready is + +```bash +# Check status +docker-compose ps + +# Test connection +docker-compose exec postgres pg_isready -U cmdb +``` + +### Stap 5: Maak Databases Aan (Optioneel) + +De applicatie maakt databases automatisch aan, maar je kunt ze ook handmatig aanmaken: + +```bash +docker-compose exec postgres psql -U cmdb -c "CREATE DATABASE cmdb_cache;" +docker-compose exec postgres psql -U cmdb -c "CREATE DATABASE cmdb_classifications;" +``` + +## Verificatie + +Na reset, check of alles werkt: + +```bash +# Connect to database +docker-compose exec postgres psql -U cmdb -d cmdb_cache + +# Check tables (zou leeg moeten zijn) +\dt + +# Exit +\q +``` + +## Wat Gebeurt Er Bij Reset? + +1. **Alle data wordt verwijderd** - Alle tabellen, objecten, relaties +2. **Volume wordt verwijderd** - PostgreSQL data directory wordt gewist +3. **Nieuwe database** - Bij volgende start is het een schone database +4. **Schema wordt automatisch aangemaakt** - Bij eerste start van backend + +## Na Reset + +1. **Start backend:** + ```bash + docker-compose up -d backend + ``` + +2. **Check logs:** + ```bash + docker-compose logs -f backend + ``` + + Je zou moeten zien: + - "NormalizedCacheStore: Database schema initialized" + - "SchemaDiscovery: Schema discovery complete" + +3. **Schema genereren (als nodig):** + ```bash + docker-compose exec backend npm run generate-schema + ``` + +4. **Start sync:** + - Via UI: Settings → Cache Management → Full Sync + - Of via API: `POST /api/cache/sync` + +## Troubleshooting + +### Volume niet gevonden + +```bash +# Check alle volumes +docker volume ls + +# Zoek naar postgres volume +docker volume ls | grep postgres +``` + +### Database bestaat al + +Als je een fout krijgt dat de database al bestaat: +```bash +# Drop en recreate +docker-compose exec postgres psql -U cmdb -c "DROP DATABASE IF EXISTS cmdb_cache;" +docker-compose exec postgres psql -U cmdb -c "CREATE DATABASE cmdb_cache;" +``` + +### Connection Issues + +```bash +# Check of PostgreSQL draait +docker-compose ps postgres + +# Check logs +docker-compose logs postgres + +# Test connection +docker-compose exec postgres pg_isready -U cmdb +``` + +## Environment Variables + +Zorg dat je `.env` bestand correct is: + +```env +DATABASE_TYPE=postgres +DATABASE_HOST=postgres +DATABASE_PORT=5432 +DATABASE_NAME=cmdb_cache +DATABASE_USER=cmdb +DATABASE_PASSWORD=cmdb-dev +``` + +Of gebruik connection string: + +```env +DATABASE_TYPE=postgres +DATABASE_URL=postgresql://cmdb:cmdb-dev@postgres:5432/cmdb_cache +``` + +## Snelle Commando's + +```bash +# Alles in één keer +docker-compose down && \ +docker volume rm cmdb-insight_postgres_data && \ +docker-compose up -d postgres && \ +sleep 5 && \ +docker-compose exec postgres pg_isready -U cmdb + +# Check database size (na sync) +docker-compose exec postgres psql -U cmdb -d cmdb_cache -c " + SELECT + pg_size_pretty(pg_database_size('cmdb_cache')) as size; +" + +# List all tables +docker-compose exec postgres psql -U cmdb -d cmdb_cache -c "\dt" + +# Count objects +docker-compose exec postgres psql -U cmdb -d cmdb_cache -c " + SELECT object_type_name, COUNT(*) + FROM objects + GROUP BY object_type_name; +" +``` diff --git a/docs/NEXT-STEPS-ACR-CREATED.md b/docs/NEXT-STEPS-ACR-CREATED.md index b0576dd..1ed53fc 100644 --- a/docs/NEXT-STEPS-ACR-CREATED.md +++ b/docs/NEXT-STEPS-ACR-CREATED.md @@ -148,7 +148,7 @@ az acr credential show --name zdlas 3. **Selecteer Repository** - Kies **"Azure Repos Git"** (of waar je code staat) - - Selecteer je repository: **"Zuyderland CMDB GUI"** (of jouw repo naam) + - Selecteer je repository: **"CMDB Insight"** (of jouw repo naam) 4. **Kies YAML File** - Kies **"Existing Azure Pipelines YAML file"** @@ -189,11 +189,11 @@ De pipeline zal: 2. **Bekijk Repositories** - Klik op **"Repositories"** (links in het menu) - Je zou moeten zien: - - `zuyderland-cmdb-gui/backend` - - `zuyderland-cmdb-gui/frontend` + - `cmdb-insight/backend` + - `cmdb-insight/frontend` 3. **Bekijk Tags** - - Klik op een repository (bijv. `zuyderland-cmdb-gui/backend`) + - Klik op een repository (bijv. `cmdb-insight/backend`) - Je zou tags moeten zien: - `latest` - `123` (of build ID nummer) @@ -207,10 +207,10 @@ De pipeline zal: az acr repository list --name zuyderlandcmdbacr # Lijst tags voor backend -az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/backend --orderby time_desc +az acr repository show-tags --name zuyderlandcmdbacr --repository cmdb-insight/backend --orderby time_desc # Lijst tags voor frontend -az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmdb-gui/frontend --orderby time_desc +az acr repository show-tags --name zuyderlandcmdbacr --repository cmdb-insight/frontend --orderby time_desc ``` ### In Azure DevOps: @@ -224,8 +224,8 @@ az acr repository show-tags --name zuyderlandcmdbacr --repository zuyderland-cmd - Bekijk de logs per stap - Bij success zie je: ``` - Backend Image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:123 - Frontend Image: zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:123 + Backend Image: zuyderlandcmdbacr.azurecr.io/cmdb-insight/backend:123 + Frontend Image: zuyderlandcmdbacr.azurecr.io/cmdb-insight/frontend:123 ``` --- @@ -281,8 +281,8 @@ Als alles goed is gegaan, heb je nu: - ✅ Docker images gebouwd en gepusht naar ACR **Je images zijn nu beschikbaar op:** -- Backend: `zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/backend:latest` -- Frontend: `zuyderlandcmdbacr.azurecr.io/zuyderland-cmdb-gui/frontend:latest` +- Backend: `zuyderlandcmdbacr.azurecr.io/cmdb-insight/backend:latest` +- Frontend: `zuyderlandcmdbacr.azurecr.io/cmdb-insight/frontend:latest` --- diff --git a/docs/NORMALIZED-DATABASE-IMPLEMENTATION-PLAN.md b/docs/NORMALIZED-DATABASE-IMPLEMENTATION-PLAN.md index e5012a5..a1c14dd 100644 --- a/docs/NORMALIZED-DATABASE-IMPLEMENTATION-PLAN.md +++ b/docs/NORMALIZED-DATABASE-IMPLEMENTATION-PLAN.md @@ -1058,4 +1058,1073 @@ const object: CMDBObject = { --- +## Detailed Implementation Guide + +### Safety Analysis + +**✅ SAFE TO IMPLEMENT** - Here's why: + +1. **Green Field Status** + - No production data exists + - No migration needed + - Clean implementation possible + +2. **Interface Compatibility** + - New `normalizedCacheStore` will implement exact same interface as `cacheStore` + - Same method signatures + - Same return types + - Drop-in replacement + +3. **Limited Dependencies** + - Only 4 files import `cacheStore`: + - `syncEngine.ts` + - `cmdbService.ts` + - `dataService.ts` + - `routes/cache.ts` + - Easy to replace all at once + +4. **Separate Database** + - Classifications database (`database.ts`) is separate + - Won't be affected by changes + - No risk to existing data + +5. **Rollback Plan** + - Old code in git history + - Can revert if needed + - No data loss risk + +### Complete Interface Definition + +The new `normalizedCacheStore` must implement this exact interface: + +```typescript +// backend/src/services/normalizedCacheStore.ts + +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 NormalizedCacheStore { + // Object Operations + async getObject(typeName: CMDBObjectTypeName, id: string): Promise; + async getObjectByKey(typeName: CMDBObjectTypeName, objectKey: string): Promise; + async getObjects(typeName: CMDBObjectTypeName, options?: QueryOptions): Promise; + async countObjects(typeName: CMDBObjectTypeName): Promise; + async searchByLabel(typeName: CMDBObjectTypeName, searchTerm: string, options?: QueryOptions): Promise; + async searchAllTypes(searchTerm: string, options?: QueryOptions): Promise; + async upsertObject(typeName: CMDBObjectTypeName, object: T): Promise; + async batchUpsertObjects(typeName: CMDBObjectTypeName, objects: T[]): Promise; + async deleteObject(typeName: CMDBObjectTypeName, id: string): Promise; + async clearObjectType(typeName: CMDBObjectTypeName): Promise; + async clearAll(): Promise; + + // Relations + async upsertRelation(sourceId: string, targetId: string, attributeName: string, sourceType: string, targetType: string): Promise; + async batchUpsertRelations(relations: Array<{sourceId: string; targetId: string; attributeName: string; sourceType: string; targetType: string}>): Promise; + async getRelatedObjects(sourceId: string, targetTypeName: CMDBObjectTypeName, attributeName?: string): Promise; + async getReferencingObjects(targetId: string, sourceTypeName: CMDBObjectTypeName, attributeName?: string): Promise; + async deleteRelationsForObject(objectId: string): Promise; + async extractAndStoreRelations(typeName: CMDBObjectTypeName, object: T): Promise; + + // Sync Metadata + async getSyncMetadata(key: string): Promise; + async setSyncMetadata(key: string, value: string): Promise; + async deleteSyncMetadata(key: string): Promise; + + // Statistics + async getStats(): Promise; + async isWarm(): Promise; + + // Utility + async close(): Promise; +} + +export const normalizedCacheStore = new NormalizedCacheStore(); +``` + +### Detailed Code Implementations + +#### 1. Database Schema (Step 1) + +**File:** `backend/src/services/database/normalized-schema.ts` + +```typescript +/** + * Normalized Database Schema + * + * Generic, schema-agnostic normalized structure for CMDB data. + */ + +export const NORMALIZED_SCHEMA_POSTGRES = ` +-- Object Types +CREATE TABLE IF NOT EXISTS object_types ( + jira_type_id INTEGER PRIMARY KEY, + type_name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + description TEXT, + sync_priority INTEGER DEFAULT 0, + object_count INTEGER DEFAULT 0, + discovered_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Attributes +CREATE TABLE IF NOT EXISTS attributes ( + id SERIAL PRIMARY KEY, + jira_attr_id INTEGER NOT NULL, + object_type_name TEXT NOT NULL REFERENCES object_types(type_name) ON DELETE CASCADE, + attr_name TEXT NOT NULL, + field_name TEXT NOT NULL, + attr_type TEXT NOT NULL, + is_multiple BOOLEAN NOT NULL DEFAULT FALSE, + is_editable BOOLEAN NOT NULL DEFAULT TRUE, + is_required BOOLEAN NOT NULL DEFAULT FALSE, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + reference_type_name TEXT, + description TEXT, + discovered_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(jira_attr_id, object_type_name) +); + +-- Objects +CREATE TABLE IF NOT EXISTS objects ( + id TEXT PRIMARY KEY, + object_key TEXT NOT NULL UNIQUE, + object_type_name TEXT NOT NULL REFERENCES object_types(type_name) ON DELETE CASCADE, + label TEXT NOT NULL, + jira_updated_at TIMESTAMP, + jira_created_at TIMESTAMP, + cached_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Attribute Values +CREATE TABLE IF NOT EXISTS attribute_values ( + id SERIAL PRIMARY KEY, + object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + attribute_id INTEGER NOT NULL REFERENCES attributes(id) ON DELETE CASCADE, + text_value TEXT, + number_value NUMERIC, + boolean_value BOOLEAN, + date_value DATE, + datetime_value TIMESTAMP, + reference_object_id TEXT, + array_index INTEGER DEFAULT 0, + UNIQUE(object_id, attribute_id, array_index) +); + +-- Relations (enhanced) +CREATE TABLE IF NOT EXISTS object_relations ( + id SERIAL PRIMARY KEY, + source_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + target_id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + attribute_id INTEGER NOT NULL REFERENCES attributes(id) ON DELETE CASCADE, + source_type TEXT NOT NULL, + target_type TEXT NOT NULL, + UNIQUE(source_id, target_id, attribute_id) +); + +-- Sync Metadata +CREATE TABLE IF NOT EXISTS sync_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_objects_type ON objects(object_type_name); +CREATE INDEX IF NOT EXISTS idx_objects_key ON objects(object_key); +CREATE INDEX IF NOT EXISTS idx_objects_label ON objects(label); +CREATE INDEX IF NOT EXISTS idx_attributes_type ON attributes(object_type_name); +CREATE INDEX IF NOT EXISTS idx_attributes_field ON attributes(field_name); +CREATE INDEX IF NOT EXISTS idx_attributes_type_field ON attributes(object_type_name, field_name); +CREATE INDEX IF NOT EXISTS idx_attr_values_object ON attribute_values(object_id); +CREATE INDEX IF NOT EXISTS idx_attr_values_attr ON attribute_values(attribute_id); +CREATE INDEX IF NOT EXISTS idx_attr_values_text ON attribute_values(text_value) WHERE text_value IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_attr_values_number ON attribute_values(number_value) WHERE number_value IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_attr_values_reference ON attribute_values(reference_object_id) WHERE reference_object_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_attr_values_composite_text ON attribute_values(attribute_id, text_value) WHERE text_value IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_attr_values_composite_ref ON attribute_values(attribute_id, reference_object_id) WHERE reference_object_id IS NOT NULL; +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_attr ON object_relations(attribute_id); +`; + +export const NORMALIZED_SCHEMA_SQLITE = ` +-- SQLite version (same structure, different syntax) +CREATE TABLE IF NOT EXISTS object_types ( + jira_type_id INTEGER PRIMARY KEY, + type_name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + description TEXT, + sync_priority INTEGER DEFAULT 0, + object_count INTEGER DEFAULT 0, + discovered_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ... (similar for all tables, using INTEGER for booleans, TEXT for timestamps) +`; +``` + +#### 2. Schema Discovery Service (Step 2) + +**File:** `backend/src/services/schemaDiscoveryService.ts` + +```typescript +import { logger } from './logger.js'; +import { createDatabaseAdapter } from './database/factory.js'; +import type { DatabaseAdapter } from './database/interface.js'; +import { OBJECT_TYPES } from '../generated/jira-schema.js'; +import type { ObjectTypeDefinition, AttributeDefinition } from '../generated/jira-schema.js'; + +class SchemaDiscoveryService { + private db: DatabaseAdapter; + private isPostgres: boolean; + private discovered: boolean = false; + + constructor() { + this.db = createDatabaseAdapter(); + this.isPostgres = (process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql'); + } + + /** + * Discover schema from OBJECT_TYPES and populate database + */ + async discoverAndStoreSchema(): Promise { + if (this.discovered) { + logger.debug('SchemaDiscovery: Schema already discovered, skipping'); + return; + } + + logger.info('SchemaDiscovery: Discovering schema from OBJECT_TYPES...'); + + await this.db.transaction(async (db) => { + // Store object types + for (const [typeName, typeDef] of Object.entries(OBJECT_TYPES)) { + await db.execute(` + INSERT INTO object_types (jira_type_id, type_name, display_name, description, sync_priority, object_count, discovered_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(jira_type_id) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + sync_priority = excluded.sync_priority, + object_count = excluded.object_count, + updated_at = excluded.updated_at + `, [ + typeDef.jiraTypeId, + typeName, + typeDef.name, + null, // description + typeDef.syncPriority, + typeDef.objectCount, + new Date().toISOString(), + new Date().toISOString() + ]); + + // Store attributes + for (const attrDef of typeDef.attributes) { + await db.execute(` + INSERT INTO attributes ( + jira_attr_id, object_type_name, attr_name, field_name, attr_type, + is_multiple, is_editable, is_required, is_system, + reference_type_name, description, discovered_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(jira_attr_id, object_type_name) DO UPDATE SET + attr_name = excluded.attr_name, + field_name = excluded.field_name, + attr_type = excluded.attr_type, + is_multiple = excluded.is_multiple, + is_editable = excluded.is_editable, + is_required = excluded.is_required, + is_system = excluded.is_system, + reference_type_name = excluded.reference_type_name, + description = excluded.description + `, [ + attrDef.jiraId, + typeName, + attrDef.name, + attrDef.fieldName, + attrDef.type, + attrDef.isMultiple, + attrDef.isEditable, + attrDef.isRequired, + attrDef.isSystem, + attrDef.referenceTypeName || null, + attrDef.description || null, + new Date().toISOString() + ]); + } + } + }); + + this.discovered = true; + logger.info('SchemaDiscovery: Schema discovery complete'); + } + + /** + * Get attribute definition from database + */ + async getAttribute(typeName: string, fieldName: string): Promise { + const row = await this.db.queryOne<{ + jira_attr_id: number; + attr_name: string; + field_name: string; + attr_type: string; + is_multiple: boolean; + is_editable: boolean; + is_required: boolean; + is_system: boolean; + reference_type_name: string | null; + description: string | null; + }>(` + SELECT * FROM attributes + WHERE object_type_name = ? AND field_name = ? + `, [typeName, fieldName]); + + if (!row) return null; + + return { + jiraId: row.jira_attr_id, + name: row.attr_name, + fieldName: row.field_name, + type: row.attr_type as AttributeDefinition['type'], + isMultiple: row.is_multiple, + isEditable: row.is_editable, + isRequired: row.is_required, + isSystem: row.is_system, + referenceTypeName: row.reference_type_name || undefined, + description: row.description || undefined, + }; + } + + /** + * Get all attributes for a type + */ + async getAttributesForType(typeName: string): Promise { + const rows = await this.db.query<{ + jira_attr_id: number; + attr_name: string; + field_name: string; + attr_type: string; + is_multiple: boolean; + is_editable: boolean; + is_required: boolean; + is_system: boolean; + reference_type_name: string | null; + description: string | null; + }>(` + SELECT * FROM attributes + WHERE object_type_name = ? + ORDER BY jira_attr_id + `, [typeName]); + + return rows.map(row => ({ + jiraId: row.jira_attr_id, + name: row.attr_name, + fieldName: row.field_name, + type: row.attr_type as AttributeDefinition['type'], + isMultiple: row.is_multiple, + isEditable: row.is_editable, + isRequired: row.is_required, + isSystem: row.is_system, + referenceTypeName: row.reference_type_name || undefined, + description: row.description || undefined, + })); + } +} + +export const schemaDiscoveryService = new SchemaDiscoveryService(); +``` + +#### 3. Normalization Logic (Step 3) + +**File:** `backend/src/services/normalizedCacheStore.ts` (partial - normalization methods) + +```typescript +/** + * Normalize a CMDBObject to database format + */ +private async normalizeObject( + object: CMDBObject, + typeName: CMDBObjectTypeName +): Promise { + // 1. Insert/update object record + await this.db.execute(` + INSERT INTO objects (id, object_key, object_type_name, label, jira_updated_at, jira_created_at, cached_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + object_key = excluded.object_key, + label = excluded.label, + jira_updated_at = excluded.jira_updated_at, + cached_at = excluded.cached_at + `, [ + object.id, + object.objectKey, + typeName, + object.label, + object._jiraUpdatedAt || null, + object._jiraCreatedAt || null, + new Date().toISOString() + ]); + + // 2. Get all attributes for this type + const attributes = await schemaDiscoveryService.getAttributesForType(typeName); + + // 3. Delete existing attribute values + await this.db.execute(` + DELETE FROM attribute_values WHERE object_id = ? + `, [object.id]); + + // 4. Store each attribute value + for (const attrDef of attributes) { + const value = (object as unknown as Record)[attrDef.fieldName]; + await this.storeAttributeValue(object.id, attrDef, value); + } +} + +/** + * Store attribute value based on type + */ +private async storeAttributeValue( + objectId: string, + attrDef: AttributeDefinition, + value: unknown +): Promise { + // Get attribute ID from database + const attrRow = await this.db.queryOne<{ id: number }>(` + SELECT id FROM attributes + WHERE object_type_name = ? AND field_name = ? + `, [attrDef.objectTypeName || '', attrDef.fieldName]); + + if (!attrRow) { + logger.warn(`NormalizedCacheStore: Attribute not found: ${attrDef.fieldName}`); + return; + } + + const attributeId = attrRow.id; + + // Handle null/undefined + if (value === null || value === undefined) { + return; // Don't store null values + } + + // Handle arrays + if (attrDef.isMultiple && Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + await this.insertAttributeValue(objectId, attributeId, value[i], attrDef, i); + } + return; + } + + // Single value + await this.insertAttributeValue(objectId, attributeId, value, attrDef, 0); +} + +/** + * Insert one attribute value row + */ +private async insertAttributeValue( + objectId: string, + attributeId: number, + value: unknown, + attrDef: AttributeDefinition, + arrayIndex: number +): Promise { + switch (attrDef.type) { + case 'reference': { + const ref = value as ObjectReference; + if (ref?.objectId) { + await this.db.execute(` + INSERT INTO attribute_values (object_id, attribute_id, reference_object_id, array_index) + VALUES (?, ?, ?, ?) + `, [objectId, attributeId, ref.objectId, arrayIndex]); + } + break; + } + + case 'text': + case 'textarea': + case 'url': + case 'email': + case 'select': + case 'user': + case 'status': { + const textValue = String(value); + await this.db.execute(` + INSERT INTO attribute_values (object_id, attribute_id, text_value, array_index) + VALUES (?, ?, ?, ?) + `, [objectId, attributeId, textValue, arrayIndex]); + break; + } + + case 'integer': + case 'float': { + const numValue = Number(value); + if (!isNaN(numValue)) { + await this.db.execute(` + INSERT INTO attribute_values (object_id, attribute_id, number_value, array_index) + VALUES (?, ?, ?, ?) + `, [objectId, attributeId, numValue, arrayIndex]); + } + break; + } + + case 'boolean': { + const boolValue = Boolean(value); + await this.db.execute(` + INSERT INTO attribute_values (object_id, attribute_id, boolean_value, array_index) + VALUES (?, ?, ?, ?) + `, [objectId, attributeId, boolValue, arrayIndex]); + break; + } + + case 'date': { + const dateValue = value as string; // ISO date string + await this.db.execute(` + INSERT INTO attribute_values (object_id, attribute_id, date_value, array_index) + VALUES (?, ?, ?, ?) + `, [objectId, attributeId, dateValue, arrayIndex]); + break; + } + + case 'datetime': { + const datetimeValue = value as string; // ISO datetime string + await this.db.execute(` + INSERT INTO attribute_values (object_id, attribute_id, datetime_value, array_index) + VALUES (?, ?, ?, ?) + `, [objectId, attributeId, datetimeValue, arrayIndex]); + break; + } + + default: { + // Fallback to text + const textValue = String(value); + await this.db.execute(` + INSERT INTO attribute_values (object_id, attribute_id, text_value, array_index) + VALUES (?, ?, ?, ?) + `, [objectId, attributeId, textValue, arrayIndex]); + } + } +} +``` + +#### 4. Reconstruction Logic (Step 4) + +**File:** `backend/src/services/normalizedCacheStore.ts` (partial - reconstruction methods) + +```typescript +/** + * Reconstruct CMDBObject from normalized data + */ +private async reconstructObject( + objectId: string, + typeName: CMDBObjectTypeName +): Promise { + // 1. Get object record + const objRow = await this.db.queryOne<{ + id: string; + object_key: string; + label: string; + jira_updated_at: string | null; + jira_created_at: string | null; + }>(` + SELECT id, object_key, label, jira_updated_at, jira_created_at + FROM objects + WHERE id = ? AND object_type_name = ? + `, [objectId, typeName]); + + if (!objRow) return null; + + // 2. Load all attribute values + const attributeValues = await this.loadAttributeValues(objectId, typeName); + + // 3. Build CMDBObject + const result: Record = { + id: objRow.id, + objectKey: objRow.object_key, + label: objRow.label, + _objectType: typeName, + _jiraUpdatedAt: objRow.jira_updated_at || new Date().toISOString(), + _jiraCreatedAt: objRow.jira_created_at || new Date().toISOString(), + ...attributeValues + }; + + return result as T; +} + +/** + * Load all attribute values for an object + */ +private async loadAttributeValues( + objectId: string, + typeName: CMDBObjectTypeName +): Promise> { + // Get all attributes for this type + const attributes = await schemaDiscoveryService.getAttributesForType(typeName); + + // Get all attribute values + const valueRows = await this.db.query<{ + attribute_id: number; + field_name: string; + attr_type: string; + is_multiple: boolean; + text_value: string | null; + number_value: number | null; + boolean_value: boolean | null; + date_value: string | null; + datetime_value: string | null; + reference_object_id: string | null; + array_index: number; + }>(` + SELECT + av.attribute_id, + a.field_name, + a.attr_type, + a.is_multiple, + av.text_value, + av.number_value, + av.boolean_value, + av.date_value, + av.datetime_value, + av.reference_object_id, + av.array_index + FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = ? + ORDER BY a.field_name, av.array_index + `, [objectId]); + + // Group by field name + const result: Record = {}; + + for (const row of valueRows) { + const fieldName = row.field_name; + const isMultiple = row.is_multiple; + + // Get value based on type + let value: unknown = null; + switch (row.attr_type) { + case 'reference': + if (row.reference_object_id) { + // Load referenced object details + const refObj = await this.getObjectById(row.reference_object_id); + if (refObj) { + value = { + objectId: refObj.id, + objectKey: refObj.objectKey, + label: refObj.label, + }; + } + } + break; + case 'text': + case 'textarea': + case 'url': + case 'email': + case 'select': + case 'user': + case 'status': + value = row.text_value; + break; + case 'integer': + case 'float': + value = row.number_value; + break; + case 'boolean': + value = row.boolean_value; + break; + case 'date': + value = row.date_value; + break; + case 'datetime': + value = row.datetime_value; + break; + default: + value = row.text_value; + } + + // Handle arrays vs single values + if (isMultiple) { + if (!result[fieldName]) { + result[fieldName] = []; + } + (result[fieldName] as unknown[]).push(value); + } else { + result[fieldName] = value; + } + } + + return result; +} + +/** + * Helper to get object by ID (for references) + */ +private async getObjectById(id: string): Promise<{ + id: string; + objectKey: string; + label: string; +} | null> { + const row = await this.db.queryOne<{ + id: string; + object_key: string; + label: string; + }>(` + SELECT id, object_key, label + FROM objects + WHERE id = ? + `, [id]); + + return row; +} +``` + +#### 5. Query Builder (Step 6) + +**File:** `backend/src/services/queryBuilder.ts` + +```typescript +import { logger } from './logger.js'; +import { schemaDiscoveryService } from './schemaDiscoveryService.js'; +import type { CMDBObjectTypeName } from '../generated/jira-types.js'; +import type { AttributeDefinition } from '../generated/jira-schema.js'; + +class QueryBuilder { + /** + * Build WHERE clause from filters + */ + async buildWhereClause( + filters: Record, + typeName: CMDBObjectTypeName + ): Promise<{ whereClause: string; params: unknown[] }> { + const conditions: string[] = ['o.object_type_name = ?']; + const params: unknown[] = [typeName]; + let paramIndex = 2; + + for (const [fieldName, filterValue] of Object.entries(filters)) { + if (filterValue === undefined || filterValue === null) continue; + + const attrDef = await schemaDiscoveryService.getAttribute(typeName, fieldName); + if (!attrDef) { + logger.debug(`QueryBuilder: Unknown field ${fieldName} for type ${typeName}, skipping`); + continue; + } + + const condition = this.buildFilterCondition(fieldName, filterValue, attrDef, paramIndex); + if (condition.condition) { + conditions.push(condition.condition); + params.push(...condition.params); + paramIndex += condition.params.length; + } + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + return { whereClause, params }; + } + + /** + * Build filter condition for one field + */ + buildFilterCondition( + fieldName: string, + filterValue: unknown, + attrDef: AttributeDefinition, + startParamIndex: number + ): { condition: string; params: unknown[] } { + // Handle special operators + if (typeof filterValue === 'object' && filterValue !== null && !Array.isArray(filterValue)) { + const filterObj = filterValue as Record; + + // Exists check + if (filterObj.exists === true) { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id AND a.field_name = ? + )`, + params: [fieldName] + }; + } + + // Empty check + if (filterObj.empty === true) { + return { + condition: `NOT EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id AND a.field_name = ? + )`, + params: [fieldName] + }; + } + + // Contains (text search) + if (filterObj.contains !== undefined && typeof filterObj.contains === 'string') { + if (attrDef.type === 'text' || attrDef.type === 'textarea') { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND LOWER(av.text_value) LIKE LOWER(?) + )`, + params: [fieldName, `%${filterObj.contains}%`] + }; + } + } + + // Reference filters + if (attrDef.type === 'reference') { + if (filterObj.objectId !== undefined) { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND av.reference_object_id = ? + )`, + params: [fieldName, String(filterObj.objectId)] + }; + } + if (filterObj.objectKey !== undefined) { + // Need to join with objects table to get objectKey + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + JOIN objects ref_obj ON av.reference_object_id = ref_obj.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND ref_obj.object_key = ? + )`, + params: [fieldName, String(filterObj.objectKey)] + }; + } + } + } + + // Handle array filters (for multiple reference fields) + if (attrDef.isMultiple && Array.isArray(filterValue)) { + if (attrDef.type === 'reference') { + const conditions: string[] = []; + const params: unknown[] = []; + for (const val of filterValue) { + if (typeof val === 'object' && val !== null) { + const ref = val as { objectId?: string; objectKey?: string }; + if (ref.objectId) { + conditions.push(`EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND av.reference_object_id = ? + )`); + params.push(fieldName, ref.objectId); + } + } + } + if (conditions.length > 0) { + return { condition: `(${conditions.join(' OR ')})`, params }; + } + } + } + + // Simple value filters + if (attrDef.type === 'reference') { + if (typeof filterValue === 'object' && filterValue !== null) { + const ref = filterValue as { objectId?: string; objectKey?: string; label?: string }; + if (ref.objectId) { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND av.reference_object_id = ? + )`, + params: [fieldName, ref.objectId] + }; + } + } + } else if (attrDef.type === 'text' || attrDef.type === 'textarea') { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND av.text_value = ? + )`, + params: [fieldName, String(filterValue)] + }; + } else if (attrDef.type === 'integer' || attrDef.type === 'float') { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND av.number_value = ? + )`, + params: [fieldName, Number(filterValue)] + }; + } else if (attrDef.type === 'boolean') { + return { + condition: `EXISTS ( + SELECT 1 FROM attribute_values av + JOIN attributes a ON av.attribute_id = a.id + WHERE av.object_id = o.id + AND a.field_name = ? + AND av.boolean_value = ? + )`, + params: [fieldName, Boolean(filterValue)] + }; + } + + return { condition: '', params: [] }; + } + + /** + * Build ORDER BY clause + */ + buildOrderBy(orderBy?: string, orderDir?: 'ASC' | 'DESC'): string { + const safeOrderBy = ['id', 'object_key', 'object_type_name', 'label', 'cached_at'].includes(orderBy || '') + ? (orderBy || 'label') + : 'label'; + const safeOrderDir = orderDir === 'DESC' ? 'DESC' : 'ASC'; + return `ORDER BY o.${safeOrderBy} ${safeOrderDir}`; + } + + /** + * Build pagination clause + */ + buildPagination(limit?: number, offset?: number): string { + const limitValue = limit || 100; + const offsetValue = offset || 0; + return `LIMIT ${limitValue} OFFSET ${offsetValue}`; + } +} + +export const queryBuilder = new QueryBuilder(); +``` + +#### 6. Query Operations (Step 7) + +**File:** `backend/src/services/normalizedCacheStore.ts` (partial - query methods) + +```typescript +/** + * Query objects with filters + */ +async queryWithFilters( + typeName: CMDBObjectTypeName, + filters: Record, + options?: QueryOptions +): Promise<{ objects: T[]; total: number }> { + await this.ensureInitialized(); + + // Build WHERE clause + const { whereClause, params: whereParams } = await queryBuilder.buildWhereClause(filters, typeName); + + // Build ORDER BY + const orderBy = queryBuilder.buildOrderBy(options?.orderBy, options?.orderDir); + + // Build pagination + const pagination = queryBuilder.buildPagination(options?.limit, options?.offset); + + // Count query + const countQuery = ` + SELECT COUNT(DISTINCT o.id) as count + FROM objects o + ${whereClause} + `; + + // Data query + const dataQuery = ` + SELECT DISTINCT o.id + FROM objects o + ${whereClause} + ${orderBy} + ${pagination} + `; + + try { + // Execute both queries + const [countResult, dataResult] = await Promise.all([ + this.db.queryOne<{ count: number }>(countQuery, whereParams), + this.db.query<{ id: string }>(dataQuery, whereParams), + ]); + + const total = countResult?.count || 0; + + // Reconstruct objects + const objects = await Promise.all( + dataResult.map(row => this.reconstructObject(row.id, typeName)) + ); + + return { + objects: objects.filter((obj): obj is T => obj !== null), + total + }; + } catch (error) { + logger.error(`NormalizedCacheStore: Query failed for ${typeName}`, error); + return { objects: [], total: 0 }; + } +} +``` + +### Implementation Checklist + +Before starting implementation, verify: + +- [ ] Green field confirmed (no production data) +- [ ] Database adapter supports both PostgreSQL and SQLite +- [ ] Schema discovery can read from `OBJECT_TYPES` +- [ ] All attribute types can be normalized +- [ ] Reconstruction logic handles all types +- [ ] Query builder supports all filter types +- [ ] Interface matches existing `cacheStore` exactly + +### Critical Implementation Notes + +1. **Schema Discovery Must Run First** + - Call `schemaDiscoveryService.discoverAndStoreSchema()` before any operations + - Can be done in `initialize()` method + +2. **Reference Loading** + - When reconstructing references, may need to load referenced objects + - Consider caching referenced objects to avoid N+1 queries + +3. **Transaction Safety** + - Use transactions for `upsertObject()` to ensure atomicity + - Delete old values before inserting new ones + +4. **Error Handling** + - Handle missing attributes gracefully + - Log warnings but don't fail entire operation + +5. **Performance** + - Batch operations where possible + - Use indexes effectively + - Consider caching attribute definitions + +--- + **End of Plan** diff --git a/docs/PRODUCTION-DEPLOYMENT.md b/docs/PRODUCTION-DEPLOYMENT.md index 319e81a..9f86303 100644 --- a/docs/PRODUCTION-DEPLOYMENT.md +++ b/docs/PRODUCTION-DEPLOYMENT.md @@ -1,6 +1,6 @@ # Productie Deployment Guide -Deze guide beschrijft hoe je de Zuyderland CMDB GUI applicatie veilig en betrouwbaar in productie kunt draaien. +Deze guide beschrijft hoe je de CMDB Insight applicatie veilig en betrouwbaar in productie kunt draaien. ## 📋 Inhoudsopgave diff --git a/docs/QUICK-DEPLOYMENT-GUIDE.md b/docs/QUICK-DEPLOYMENT-GUIDE.md index 5822a8f..9a40a31 100644 --- a/docs/QUICK-DEPLOYMENT-GUIDE.md +++ b/docs/QUICK-DEPLOYMENT-GUIDE.md @@ -39,14 +39,14 @@ az webapp create \ --name cmdb-backend-prod \ --resource-group rg-cmdb-gui \ --plan plan-cmdb-gui \ - --deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest + --deployment-container-image-name zdlas.azurecr.io/cmdb-insight/backend:latest # Frontend Web App az webapp create \ --name cmdb-frontend-prod \ --resource-group rg-cmdb-gui \ --plan plan-cmdb-gui \ - --deployment-container-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest + --deployment-container-image-name zdlas.azurecr.io/cmdb-insight/frontend:latest ``` ### Stap 3: Configureer ACR Authentication @@ -70,7 +70,7 @@ az acr update \ az webapp config container set \ --name cmdb-backend-prod \ --resource-group rg-cmdb-gui \ - --docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/backend:latest \ + --docker-custom-image-name zdlas.azurecr.io/cmdb-insight/backend:latest \ --docker-registry-server-url https://zdlas.azurecr.io \ --docker-registry-server-user $(az acr credential show --name zdlas --query username -o tsv) \ --docker-registry-server-password $(az acr credential show --name zdlas --query passwords[0].value -o tsv) @@ -78,7 +78,7 @@ az webapp config container set \ az webapp config container set \ --name cmdb-frontend-prod \ --resource-group rg-cmdb-gui \ - --docker-custom-image-name zdlas.azurecr.io/zuyderland-cmdb-gui/frontend:latest \ + --docker-custom-image-name zdlas.azurecr.io/cmdb-insight/frontend:latest \ --docker-registry-server-url https://zdlas.azurecr.io \ --docker-registry-server-user $(az acr credential show --name zdlas --query username -o tsv) \ --docker-registry-server-password $(az acr credential show --name zdlas --query passwords[0].value -o tsv) @@ -190,7 +190,7 @@ docker login zdlas.azurecr.io -u -p ```bash # Clone repository git clone -cd zuyderland-cmdb-gui +cd cmdb-insight # Maak .env.production aan nano .env.production diff --git a/docs/REFACTOR-PHASE-2B-3-STATUS.md b/docs/REFACTOR-PHASE-2B-3-STATUS.md new file mode 100644 index 0000000..767638e --- /dev/null +++ b/docs/REFACTOR-PHASE-2B-3-STATUS.md @@ -0,0 +1,283 @@ +# Refactor Phase 2B + 3: Implementation Status + +**Date:** 2025-01-XX +**Status:** ✅ Phase 2B Complete - New Architecture Implemented +**Next:** Phase 3 - Migration & Cleanup + +## Summary + +New refactored architecture has been fully implemented and wired behind feature flag `USE_V2_API=true`. All new services, repositories, and API controllers are in place. + +--- + +## ✅ Completed Components + +### Infrastructure Layer (`/infrastructure`) + +1. **`infrastructure/jira/JiraAssetsClient.ts`** ✅ + - Pure HTTP API client (no business logic) + - Methods: `getObject()`, `searchObjects()`, `updateObject()`, `getSchemas()`, `getObjectTypes()`, `getAttributes()` + - Token management (service account for reads, user PAT for writes) + - Returns `ObjectEntry` from domain types + +### Domain Layer (`/domain`) + +1. **`domain/jiraAssetsPayload.ts`** ✅ (Phase 2A) + - Complete API payload contract + - Type guards: `isReferenceValue()`, `isSimpleValue()`, `hasAttributes()` + +2. **`domain/syncPolicy.ts`** ✅ + - `SyncPolicy` enum (ENABLED, REFERENCE_ONLY, SKIP) + - Policy resolution logic + +### Repository Layer (`/repositories`) + +1. **`repositories/SchemaRepository.ts`** ✅ + - Schema CRUD: `upsertSchema()`, `getAllSchemas()` + - Object type CRUD: `upsertObjectType()`, `getEnabledObjectTypes()`, `getObjectTypeByJiraId()` + - Attribute CRUD: `upsertAttribute()`, `getAttributesForType()`, `getAttributeByFieldName()` + +2. **`repositories/ObjectCacheRepository.ts`** ✅ + - Object CRUD: `upsertObject()`, `getObject()`, `getObjectByKey()`, `deleteObject()` + - Attribute value CRUD: `upsertAttributeValue()`, `batchUpsertAttributeValues()`, `getAttributeValues()`, `deleteAttributeValues()` + - Relations: `upsertRelation()`, `deleteRelations()` + - Queries: `getObjectsByType()`, `countObjectsByType()` + +### Service Layer (`/services`) + +1. **`services/PayloadProcessor.ts`** ✅ + - **Recursive reference processing** with visited-set cycle detection + - Processes `ObjectEntry` and `ReferencedObject` recursively (level2, level3, etc.) + - **CRITICAL**: Only replaces attributes if `attributes[]` array is present + - Extracts relations from references + - Normalizes to EAV format + +2. **`services/SchemaSyncService.ts`** ✅ + - Syncs schemas from Jira API: `syncAllSchemas()` + - Discovers and stores object types and attributes + - Returns enabled types for sync orchestration + +3. **`services/ObjectSyncService.ts`** ✅ + - Full sync: `syncObjectType()` - syncs all objects of an enabled type + - Incremental sync: `syncIncremental()` - syncs objects updated since timestamp + - Single object sync: `syncSingleObject()` - for refresh operations + - Recursive processing via `PayloadProcessor` + - Respects `SyncPolicy` (ENABLED vs REFERENCE_ONLY) + +4. **`services/QueryService.ts`** ✅ + - Universal query builder (DB → TypeScript) + - `getObject()` - reconstruct single object + - `getObjects()` - list objects of type + - `countObjects()` - count by type + - `searchByLabel()` - search by label + +5. **`services/RefreshService.ts`** ✅ + - Force-refresh-on-read with deduplication + - Locking mechanism prevents duplicate refresh operations + - Timeout protection (30s) + +6. **`services/WriteThroughService.ts`** ✅ + - Write-through updates: Jira API → DB cache + - Builds Jira update payload from field updates + - Uses same normalization logic as sync + +7. **`services/ServiceFactory.ts`** ✅ + - Singleton factory for all services + - Initializes all dependencies + - Single entry point: `getServices()` + +### API Layer (`/api`) + +1. **`api/controllers/ObjectsController.ts`** ✅ + - `GET /api/v2/objects/:type` - List objects + - `GET /api/v2/objects/:type/:id?refresh=true` - Get object (with force refresh) + - `PUT /api/v2/objects/:type/:id` - Update object + +2. **`api/controllers/SyncController.ts`** ✅ + - `POST /api/v2/sync/schemas` - Sync all schemas + - `POST /api/v2/sync/objects` - Sync all enabled types + - `POST /api/v2/sync/objects/:typeName` - Sync single type + +3. **`api/routes/v2.ts`** ✅ + - V2 routes mounted at `/api/v2` + - Feature flag: `USE_V2_API=true` enables routes + - All routes require authentication + +### Integration (`/backend/src/index.ts`) + +✅ V2 routes wired with feature flag +✅ Token management for new `JiraAssetsClient` +✅ Backward compatible with old services + +--- + +## 🔧 Key Features Implemented + +### 1. Recursive Reference Processing ✅ + +- **Cycle detection**: Visited set using `objectId:objectKey` keys +- **Recursive expansion**: Processes `referencedObject.attributes[]` (level2, level3, etc.) +- **Preserves shallow objects**: Doesn't wipe attributes if `attributes[]` absent + +### 2. Sync Policy Enforcement ✅ + +- **ENABLED**: Full sync with all attributes +- **REFERENCE_ONLY**: Cache minimal metadata for references +- **SKIP**: Unknown types skipped + +### 3. Attribute Replacement Logic ✅ + +**CRITICAL RULE**: Only replaces attributes if `attributes[]` array is present in API response. + +```typescript +if (shouldCacheAttributes) { + // attributes[] present - full replace + await deleteAttributeValues(objectId); + await batchUpsertAttributeValues(...); +} +// If attributes[] absent - keep existing attributes +``` + +### 4. Write-Through Updates ✅ + +1. Build Jira update payload +2. Send to Jira Assets API +3. Fetch fresh data +4. Update DB cache using same normalization + +### 5. Force Refresh with Deduping ✅ + +- Lock mechanism prevents duplicate refreshes +- Timeout protection (30s) +- Concurrent reads allowed + +--- + +## 📁 File Structure + +``` +backend/src/ +├── domain/ +│ ├── jiraAssetsPayload.ts ✅ Phase 2A +│ └── syncPolicy.ts ✅ New +├── infrastructure/ +│ └── jira/ +│ └── JiraAssetsClient.ts ✅ New (pure API) +├── repositories/ +│ ├── SchemaRepository.ts ✅ New +│ └── ObjectCacheRepository.ts ✅ New +├── services/ +│ ├── PayloadProcessor.ts ✅ New (recursive) +│ ├── SchemaSyncService.ts ✅ New +│ ├── ObjectSyncService.ts ✅ New +│ ├── QueryService.ts ✅ New +│ ├── RefreshService.ts ✅ New +│ ├── WriteThroughService.ts ✅ New +│ └── ServiceFactory.ts ✅ New +└── api/ + ├── controllers/ + │ ├── ObjectsController.ts ✅ New + │ └── SyncController.ts ✅ New + └── routes/ + └── v2.ts ✅ New +``` + +--- + +## 🚀 Usage (Feature Flag) + +### Enable V2 API + +```bash +# .env +USE_V2_API=true +``` + +### New Endpoints + +``` +GET /api/v2/objects/:type # List objects +GET /api/v2/objects/:type/:id?refresh=true # Get object (with refresh) +PUT /api/v2/objects/:type/:id # Update object + +POST /api/v2/sync/schemas # Sync all schemas +POST /api/v2/sync/objects # Sync all enabled types +POST /api/v2/sync/objects/:typeName # Sync single type +``` + +--- + +## ✅ API Payload Contract Compliance + +All services correctly handle: + +- ✅ `objectEntries[]` → `ObjectEntry[]` +- ✅ `ObjectEntry.attributes[]` → `ObjectAttribute[]` (optional) +- ✅ `ObjectAttribute.objectAttributeValues[]` → `ObjectAttributeValue` union +- ✅ `ReferencedObject.attributes[]` → Recursive (level2+) +- ✅ Cycle detection with visited sets +- ✅ **CRITICAL**: Don't wipe attributes if `attributes[]` absent on shallow objects + +--- + +## 🧪 Testing Status + +**Compilation**: ✅ New code compiles without errors (pre-existing TypeScript config issues unrelated) + +**Ready for Testing**: +1. Enable `USE_V2_API=true` +2. Test new endpoints +3. Verify recursive reference processing +4. Verify attribute replacement logic +5. Verify write-through updates + +--- + +## 📋 Next Steps (Phase 3) + +### Step 1: Test V2 API ✅ (Ready) + +- [ ] Enable feature flag +- [ ] Test schema sync endpoint +- [ ] Test object sync endpoint +- [ ] Test object read endpoint +- [ ] Test object write endpoint +- [ ] Verify recursive references processed +- [ ] Verify attribute replacement logic + +### Step 2: Migrate Existing Endpoints + +After V2 API is validated: + +- [ ] Update `routes/objects.ts` to use new services +- [ ] Update `routes/cache.ts` to use new services +- [ ] Update `routes/schema.ts` to use new services + +### Step 3: Delete Old Code + +After migration complete: + +- [ ] Delete `services/jiraAssets.ts` (merge remaining business logic first) +- [ ] Delete `services/jiraAssetsClient.ts` (replaced by infrastructure client) +- [ ] Delete `services/cacheStore.old.ts` +- [ ] Delete `services/normalizedCacheStore.ts` (replace with repositories) +- [ ] Delete `services/queryBuilder.ts` (functionality in QueryService) +- [ ] Delete `services/schemaDiscoveryService.ts` (replaced by SchemaSyncService) +- [ ] Delete `services/schemaCacheService.ts` (merged into SchemaRepository) +- [ ] Delete `services/schemaConfigurationService.ts` (functionality moved to SchemaRepository) +- [ ] Delete `services/schemaMappingService.ts` (deprecated) +- [ ] Delete `services/syncEngine.ts` (replaced by ObjectSyncService) +- [ ] Delete `services/cmdbService.ts` (functionality split into QueryService + WriteThroughService + RefreshService) + +--- + +## ⚠️ Important Notes + +1. **No Functional Changes Yet**: Old code still runs in parallel +2. **Feature Flag Required**: V2 API only active when `USE_V2_API=true` +3. **Token Management**: New client receives tokens from middleware (same as old) +4. **Database Schema**: Uses existing normalized EAV schema (no migration needed) + +--- + +**End of Phase 2B + 3 Implementation Status** diff --git a/docs/SCHEMA-DISCOVERY-FLOW.md b/docs/SCHEMA-DISCOVERY-FLOW.md new file mode 100644 index 0000000..b7f8191 --- /dev/null +++ b/docs/SCHEMA-DISCOVERY-FLOW.md @@ -0,0 +1,116 @@ +# Schema Discovery and Data Loading Flow + +## Overview + +This document describes the logical flow for setting up and using CMDB Insight, from schema discovery to data viewing. + +## Implementation Verification + +✅ **Data Structure Alignment**: The data structure from Jira Assets REST API **matches** the discovered schema structure. There are **no fallbacks or guessing** - the system uses the discovered schema exclusively. + +### How It Works + +1. **Schema Discovery** (`/api/schema-configuration/discover`): + - Discovers all schemas and object types from Jira Assets API + - Stores them in the `schemas` and `object_types` database tables + +2. **Attribute Discovery** (automatic after schema discovery): + - `schemaDiscoveryService.discoverAndStoreSchema()` fetches attributes for each object type + - Stores attributes in the `attributes` table with: + - `jira_attr_id` - Jira's attribute ID + - `field_name` - Our internal field name (camelCase) + - `attr_type` - Data type (text, reference, integer, etc.) + - `is_multiple` - Whether it's a multi-value field + +3. **Data Loading** (when syncing): + - `jiraAssetsClient.parseObject()` uses the discovered schema from the database + - Maps Jira API attributes to our field names using `jira_attr_id` matching + - **No fallbacks** - if a type is not discovered, the object is skipped (returns null) + +### Code Flow + +``` +Schema Discovery → Database (attributes table) → schemaCacheService.getSchema() +→ OBJECT_TYPES_CACHE → jiraAssetsClient.parseObject() → CMDBObject +``` + +## User Flow + +### Step 1: Schema Discovery & Configuration +**Page**: `/settings/schema-configuration` + +1. **Discover Schemas**: Click "Ontdek Schemas & Object Types" + - Fetches all schemas and object types from Jira Assets + - Stores them in the database + +2. **Configure Object Types**: Enable/disable which object types to sync + - By default, all object types are disabled + - Enable the object types you want to sync + +3. **Manual Sync**: Click "Nu synchroniseren" (in CacheStatusIndicator) + - Loads data from Jira Assets REST API + - Uses the discovered schema structure to map attributes + - Stores objects in the normalized database + +### Step 2: View Data Structure +**Page**: `/settings/data-model` + +- View the discovered schema structure +- See object types, attributes, and relationships +- Verify that the structure matches your Jira Assets configuration + +### Step 3: Validate Data +**Page**: `/settings/data-validation` + +- Validate data integrity +- Check for missing or invalid data +- Debug data loading issues + +### Step 4: Use Application Components +**Pages**: `/application/overview`, `/app-components`, etc. + +- View and manage application components +- All data uses the discovered schema structure + +## Navigation Structure + +The navigation menu follows this logical flow: + +1. **Setup** - Initial configuration + - Schema Configuratie (discover + configure + sync) + - Datamodel Overzicht (view structure) + +2. **Data** - Data management + - Datamodel (view structure) + - Data Validatie (validate data) + +3. **Application Component** - Application management + - Dashboard, Overzicht, FTE Calculator + +4. **Rapporten** - Reports and analytics + +5. **Apps** - Additional tools + - BIA Sync + +6. **Instellingen** - Advanced configuration + - Data Completeness Config + - FTE Config + +7. **Beheer** - Administration + - Users, Roles, Debug + +## Key Points + +- ✅ **No guessing**: The system uses the discovered schema exclusively +- ✅ **No fallbacks**: If a type is not discovered, objects are skipped +- ✅ **Schema-driven**: All data mapping uses the discovered schema structure +- ✅ **Database-driven**: Schema is stored in the database, not hardcoded + +## Troubleshooting + +If data is not loading correctly: + +1. **Check Schema Discovery**: Ensure schemas and object types are discovered +2. **Check Configuration**: Ensure at least one object type is enabled +3. **Check Attributes**: Verify that attributes are discovered (check `/settings/data-model`) +4. **Check Logs**: Look for "Unknown object type" or "Type definition not found" warnings diff --git a/docs/zira-classificatie-tool-specificatie.md b/docs/cmdb-insight-specificatie.md similarity index 99% rename from docs/zira-classificatie-tool-specificatie.md rename to docs/cmdb-insight-specificatie.md index 7140b65..0f83bd7 100644 --- a/docs/zira-classificatie-tool-specificatie.md +++ b/docs/cmdb-insight-specificatie.md @@ -1,4 +1,4 @@ -# ZiRA Classificatie Tool - Technische Specificatie +# CMDB Insight - Technische Specificatie ## Projectoverzicht @@ -23,7 +23,7 @@ Ontwikkelen van een interactieve tool voor het classificeren van applicatiecompo ``` ┌─────────────────────────────────────────────────────────────────┐ -│ ZiRA Classificatie Tool │ +│ CMDB Insight │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │ @@ -870,7 +870,7 @@ interface ReferenceOptions { ## Project Structuur ``` -zira-classificatie-tool/ +cmdb-insight/ ├── package.json ├── .env.example ├── README.md diff --git a/docs/refactor-plan.md b/docs/refactor-plan.md new file mode 100644 index 0000000..a5ac3c9 --- /dev/null +++ b/docs/refactor-plan.md @@ -0,0 +1,801 @@ +# Refactor Plan - Phase 1: Architecture Analysis + +**Created:** 2025-01-XX +**Status:** Phase 1 - Analysis Only (No functional changes) + +## Executive Summary + +This document provides a comprehensive analysis of the current architecture and a plan for refactoring the CMDB Insight codebase to improve maintainability, reduce duplication, and establish clearer separation of concerns. + +**Scope:** This is Phase 1 - analysis and planning only. No code changes will be made in this phase. + +--- + +## Table of Contents + +1. [Current Architecture Map](#current-architecture-map) +2. [Pain Points & Duplication](#pain-points--duplication) +3. [Target Architecture](#target-architecture) +4. [Migration Steps](#migration-steps) +5. [Explicit Deletion List](#explicit-deletion-list) +6. [API Payload Contract & Recursion Insights](#api-payload-contract--recursion-insights) + +--- + +## Current Architecture Map + +### File/Folder Structure + +``` +backend/src/ +├── services/ +│ ├── jiraAssets.ts # High-level Jira Assets service (business logic, ~3454 lines) +│ ├── jiraAssetsClient.ts # Low-level Jira Assets API client (~646 lines) +│ ├── schemaDiscoveryService.ts # Discovers schema from Jira API (~520 lines) +│ ├── schemaCacheService.ts # Caches schema metadata +│ ├── schemaConfigurationService.ts # Manages enabled object types +│ ├── schemaMappingService.ts # Maps object types to schema IDs +│ ├── syncEngine.ts # Background sync service (full/incremental) (~630 lines) +│ ├── normalizedCacheStore.ts # EAV pattern DB store (~1695 lines) +│ ├── cmdbService.ts # Universal schema-driven CMDB service (~531 lines) +│ ├── queryBuilder.ts # Dynamic SQL query builder (~278 lines) +│ ├── cacheStore.old.ts # Legacy cache store (deprecated) +│ └── database/ +│ ├── normalized-schema.ts # DB schema definitions (Postgres/SQLite) +│ ├── factory.ts # Database adapter factory +│ ├── interface.ts # Database adapter interface +│ ├── postgresAdapter.ts # PostgreSQL adapter +│ ├── sqliteAdapter.ts # SQLite adapter +│ ├── migrate-to-normalized-schema.ts +│ └── fix-object-types-constraints.ts +├── routes/ +│ ├── applications.ts # Application-specific endpoints (~780 lines) +│ ├── objects.ts # Generic object endpoints (~185 lines) +│ ├── cache.ts # Cache/sync endpoints (~165 lines) +│ ├── schema.ts # Schema endpoints (~107 lines) +│ └── schemaConfiguration.ts # Schema configuration endpoints +├── generated/ +│ ├── jira-types.ts # Generated TypeScript types (~934 lines) +│ └── jira-schema.ts # Generated schema metadata (~895 lines) +└── scripts/ + ├── discover-schema.ts # Schema discovery CLI + ├── generate-types-from-db.ts # Type generation from DB (~485 lines) + └── generate-schema.ts # Legacy schema generation +``` + +### Module Responsibilities + +#### 1. Jira Assets API Client Calls + +**Primary Files:** +- `services/jiraAssetsClient.ts` - Low-level HTTP client + - Methods: `getObject()`, `searchObjects()`, `getAllObjectsOfType()`, `updateObject()`, `parseObject()` + - Handles authentication (service account token for reads, user PAT for writes) + - API detection (Data Center vs Cloud) + - Object parsing from Jira format to CMDB format + +- `services/jiraAssets.ts` - High-level business logic wrapper + - Application-specific methods (e.g., `getApplications()`, `updateApplication()`) + - Dashboard data aggregation + - Reference data caching + - Team dashboard calculations + - Legacy API methods + +**Dependencies:** +- Uses `schemaCacheService` for type lookups +- Uses `schemaMappingService` for schema ID resolution + +#### 2. Schema Discovery/Sync + +**Primary Files:** +- `services/schemaDiscoveryService.ts` + - Discovers object types from Jira API (`/objectschema/{id}/objecttypes/flat`) + - Discovers attributes for each object type (`/objecttype/{id}/attributes`) + - Stores schema in database (`object_types`, `attributes` tables) + - Provides lookup methods: `getAttribute()`, `getAttributesForType()`, `getObjectType()` + +- `services/schemaCacheService.ts` + - Caches schema from database + - Provides runtime schema access + +- `services/schemaConfigurationService.ts` + - Manages enabled/disabled object types + - Schema-to-object-type mapping + - Configuration validation + +- `services/schemaMappingService.ts` + - Maps object type names to schema IDs + - Legacy compatibility + +**Scripts:** +- `scripts/discover-schema.ts` - CLI tool to trigger schema discovery +- `scripts/generate-types-from-db.ts` - Generates TypeScript types from database + +#### 3. Object Sync/Import + +**Primary Files:** +- `services/syncEngine.ts` + - `fullSync()` - Syncs all enabled object types + - `incrementalSync()` - Periodic sync of updated objects + - `syncType()` - Sync single object type + - `syncObject()` - Sync single object + - Uses `jiraAssetsClient.getAllObjectsOfType()` for fetching + - Uses `normalizedCacheStore.batchUpsertObjects()` for storage + +**Flow:** +1. Fetch objects from Jira via `jiraAssetsClient` +2. Parse objects via `jiraAssetsClient.parseObject()` +3. Store objects via `normalizedCacheStore.batchUpsertObjects()` +4. Extract relations via `normalizedCacheStore.extractAndStoreRelations()` + +#### 4. DB Normalization Store (EAV) + +**Primary Files:** +- `services/normalizedCacheStore.ts` (~1695 lines) + - **Storage:** `normalizeObject()`, `batchUpsertObjects()`, `upsertObject()` + - **Retrieval:** `getObject()`, `getObjects()`, `reconstructObject()`, `loadAttributeValues()` + - **Relations:** `extractAndStoreRelations()`, `getRelatedObjects()`, `getReferencingObjects()` + - **Query:** `queryWithFilters()` (uses `queryBuilder`) + +- `services/database/normalized-schema.ts` + - Defines EAV schema: `objects`, `attributes`, `attribute_values`, `object_relations` + +**EAV Pattern:** +- `objects` table: Minimal metadata (id, objectKey, label, type, timestamps) +- `attributes` table: Schema metadata (jira_attr_id, field_name, type, is_multiple, etc.) +- `attribute_values` table: Actual values (text_value, number_value, boolean_value, reference_object_id, array_index) +- `object_relations` table: Extracted relationships (source_id, target_id, attribute_id) + +#### 5. Backend API Endpoints + +**Primary Files:** +- `routes/applications.ts` - Application-specific endpoints + - `POST /applications/search` - Search with filters + - `GET /applications/:id` - Get application details + - `PUT /applications/:id` - Update application + - `GET /applications/:id/related/:type` - Get related objects + - Dashboard endpoints (`/team-dashboard`, `/team-portfolio-health`) + +- `routes/objects.ts` - Generic object endpoints + - `GET /objects` - List supported types + - `GET /objects/:type` - Get all objects of type + - `GET /objects/:type/:id` - Get single object + - `GET /objects/:type/:id/related/:relationType` - Get related objects + +- `routes/cache.ts` - Cache management + - `POST /cache/sync` - Trigger full sync + - `POST /cache/sync/:objectType` - Sync single type + - `POST /cache/refresh-application/:id` - Refresh single object + +- `routes/schema.ts` - Schema endpoints + - `GET /schema` - Get schema metadata + - `GET /schema/types` - List object types + - `GET /schema/types/:type` - Get type definition + +**Service Layer:** +- Routes delegate to `cmdbService`, `dataService`, `syncEngine` +- `cmdbService` provides unified interface (read/write with conflict detection) +- `dataService` provides application-specific business logic + +#### 6. Query Builder (Object Reconstruction) + +**Primary Files:** +- `services/queryBuilder.ts` + - `buildWhereClause()` - Builds WHERE conditions from filters + - `buildFilterCondition()` - Handles different attribute types (text, reference, number, etc.) + - `buildOrderBy()` - ORDER BY clause + - `buildPagination()` - LIMIT/OFFSET clause + +**Usage:** +- Used by `normalizedCacheStore.queryWithFilters()` to build dynamic SQL +- Handles complex filters (exact match, exists, contains, reference filters) + +#### 7. Generated Types/Reflect Scripts + +**Primary Files:** +- `scripts/generate-types-from-db.ts` + - Reads from `object_types` and `attributes` tables + - Generates `generated/jira-types.ts` (TypeScript interfaces) + - Generates `generated/jira-schema.ts` (Schema metadata with lookup maps) + +**Generated Output:** +- `jira-types.ts`: TypeScript interfaces for each object type (e.g., `ApplicationComponent`, `Server`) +- `jira-schema.ts`: `OBJECT_TYPES` record, lookup maps (`TYPE_ID_TO_NAME`, `JIRA_NAME_TO_TYPE`), helper functions + +--- + +## Pain Points & Duplication + +### 1. Dual API Clients (jiraAssets.ts vs jiraAssetsClient.ts) + +**Issue:** Two separate services handling Jira API calls: +- `jiraAssetsClient.ts` - Low-level, focused on API communication +- `jiraAssets.ts` - High-level, contains business logic + API calls + +**Problems:** +- Duplication of API request logic +- Inconsistent error handling +- Mixed concerns (business logic + infrastructure) +- `jiraAssets.ts` is huge (~3454 lines) and hard to maintain + +**Location:** +- `backend/src/services/jiraAssets.ts` - Contains both API calls and business logic +- `backend/src/services/jiraAssetsClient.ts` - Clean separation but incomplete + +### 2. Schema Discovery/Caching Duplication + +**Issue:** Multiple services handling schema metadata: +- `schemaDiscoveryService.ts` - Discovers and stores schema +- `schemaCacheService.ts` - Caches schema from DB +- `schemaConfigurationService.ts` - Manages enabled types +- `schemaMappingService.ts` - Maps types to schema IDs + +**Problems:** +- Unclear boundaries between services +- Potential for stale cache +- Complex initialization dependencies + +**Location:** +- `backend/src/services/schema*.ts` files + +### 3. Mixed Responsibilities in normalizedCacheStore.ts + +**Issue:** Large file (~1695 lines) handling multiple concerns: +- Database operations (EAV storage/retrieval) +- Object reconstruction (TypeScript object building) +- Reference resolution (fetching missing referenced objects) +- Relation extraction + +**Problems:** +- Hard to test individual concerns +- Difficult to optimize specific operations +- Violates single responsibility principle + +**Location:** +- `backend/src/services/normalizedCacheStore.ts` + +### 4. Application-Specific Logic in Generic Services + +**Issue:** Application-specific business logic scattered: +- `routes/applications.ts` - Application-specific endpoints (~780 lines) +- `services/dataService.ts` - Application business logic +- `services/jiraAssets.ts` - Application aggregation logic +- `services/cmdbService.ts` - Generic service used by applications + +**Problems:** +- Hard to extend to other object types +- Tight coupling between routes and services +- Business logic mixed with data access + +**Location:** +- `backend/src/routes/applications.ts` +- `backend/src/services/dataService.ts` + +### 5. Type Generation Pipeline Complexity + +**Issue:** Multiple scripts and services involved in type generation: +- `scripts/discover-schema.ts` - Triggers schema discovery +- `services/schemaDiscoveryService.ts` - Discovers schema +- `scripts/generate-types-from-db.ts` - Generates TypeScript files +- `generated/jira-types.ts` - Generated output (must be regenerated when schema changes) + +**Problems:** +- Unclear workflow +- Manual steps required +- Generated files can get out of sync + +**Location:** +- `backend/scripts/discover-schema.ts` +- `backend/scripts/generate-types-from-db.ts` +- `backend/src/generated/*.ts` + +### 6. Legacy Code (cacheStore.old.ts) + +**Issue:** Old cache store still present in codebase: +- `services/cacheStore.old.ts` - Deprecated implementation + +**Problems:** +- Confusing for new developers +- Takes up space +- No longer used + +**Location:** +- `backend/src/services/cacheStore.old.ts` + +### 7. Inconsistent Error Handling + +**Issue:** Different error handling patterns across services: +- Some use try/catch with logger +- Some throw errors +- Some return null/undefined +- Inconsistent error messages + +**Problems:** +- Hard to debug issues +- Inconsistent API responses +- No centralized error handling + +**Location:** +- Throughout codebase + +--- + +## Target Architecture + +### Domain/Infrastructure/Services/API Separation + +``` +backend/src/ +├── domain/ # Domain models & business logic +│ ├── cmdb/ +│ │ ├── Object.ts # CMDBObject base interface +│ │ ├── ObjectType.ts # ObjectTypeDefinition +│ │ ├── Attribute.ts # AttributeDefinition +│ │ └── Reference.ts # ObjectReference +│ ├── schema/ +│ │ ├── Schema.ts # Schema domain model +│ │ └── SchemaDiscovery.ts # Schema discovery business logic +│ └── sync/ +│ ├── SyncEngine.ts # Sync orchestration logic +│ └── SyncStrategy.ts # Sync strategies (full, incremental) +│ +├── infrastructure/ # External integrations & infrastructure +│ ├── jira/ +│ │ ├── JiraAssetsClient.ts # Low-level HTTP client (pure API calls) +│ │ ├── JiraAssetsApi.ts # API contract definitions +│ │ └── JiraResponseParser.ts # Response parsing utilities +│ └── database/ +│ ├── adapters/ # Database adapters (Postgres, SQLite) +│ ├── schema/ # Schema definitions +│ └── migrations/ # Database migrations +│ +├── services/ # Application services (use cases) +│ ├── cmdb/ +│ │ ├── CmdbReadService.ts # Read operations +│ │ ├── CmdbWriteService.ts # Write operations with conflict detection +│ │ └── CmdbQueryService.ts # Query operations +│ ├── schema/ +│ │ ├── SchemaService.ts # Schema CRUD operations +│ │ └── SchemaDiscoveryService.ts # Schema discovery orchestration +│ └── sync/ +│ └── SyncService.ts # Sync orchestration +│ +├── repositories/ # Data access layer +│ ├── ObjectRepository.ts # Object CRUD (uses EAV store) +│ ├── AttributeRepository.ts # Attribute value access +│ ├── RelationRepository.ts # Relationship access +│ └── SchemaRepository.ts # Schema metadata access +│ +├── stores/ # Storage implementations +│ ├── NormalizedObjectStore.ts # EAV pattern implementation +│ ├── ObjectReconstructor.ts # Object reconstruction from EAV +│ └── RelationExtractor.ts # Relation extraction logic +│ +├── api/ # HTTP API layer +│ ├── routes/ +│ │ ├── objects.ts # Generic object endpoints +│ │ ├── schema.ts # Schema endpoints +│ │ └── sync.ts # Sync endpoints +│ ├── handlers/ # Request handlers (thin layer) +│ │ ├── ObjectHandler.ts +│ │ ├── SchemaHandler.ts +│ │ └── SyncHandler.ts +│ └── middleware/ # Auth, validation, etc. +│ +├── queries/ # Query builders +│ ├── ObjectQueryBuilder.ts # SQL query construction +│ └── FilterBuilder.ts # Filter condition builder +│ +└── scripts/ # CLI tools + ├── discover-schema.ts # Schema discovery CLI + └── generate-types.ts # Type generation CLI +``` + +### Key Principles + +1. **Domain Layer**: Pure business logic, no infrastructure dependencies +2. **Infrastructure Layer**: External integrations (Jira API, database) +3. **Services Layer**: Orchestrates domain logic and infrastructure +4. **Repository Layer**: Data access abstraction +5. **Store Layer**: Storage implementations (EAV, caching) +6. **API Layer**: Thin HTTP handlers that delegate to services + +--- + +## Migration Steps + +### Step 1: Extract Jira API Client (Infrastructure) + +**Goal:** Create pure infrastructure client with no business logic + +1. Consolidate `jiraAssetsClient.ts` and `jiraAssets.ts` API methods into single `JiraAssetsClient` +2. Extract API contract types to `infrastructure/jira/JiraAssetsApi.ts` +3. Move response parsing to `infrastructure/jira/JiraResponseParser.ts` +4. Remove business logic from API client (delegate to services) + +**Files to Create:** +- `infrastructure/jira/JiraAssetsClient.ts` +- `infrastructure/jira/JiraAssetsApi.ts` +- `infrastructure/jira/JiraResponseParser.ts` + +**Files to Modify:** +- `services/jiraAssets.ts` - Remove API calls, keep business logic +- `services/jiraAssetsClient.ts` - Merge into infrastructure client + +**Files to Delete:** +- None (yet - deprecate old files after migration) + +### Step 2: Extract Schema Domain & Services + +**Goal:** Separate schema discovery business logic from infrastructure + +1. Create `domain/schema/` with domain models +2. Move schema discovery logic to `services/schema/SchemaDiscoveryService.ts` +3. Consolidate schema caching in `services/schema/SchemaService.ts` +4. Remove duplication between `schemaCacheService`, `schemaConfigurationService`, `schemaMappingService` + +**Files to Create:** +- `domain/schema/Schema.ts` +- `services/schema/SchemaService.ts` +- `services/schema/SchemaDiscoveryService.ts` + +**Files to Modify:** +- `services/schemaDiscoveryService.ts` - Split into domain + service +- `services/schemaCacheService.ts` - Merge into SchemaService +- `services/schemaConfigurationService.ts` - Merge into SchemaService +- `services/schemaMappingService.ts` - Merge into SchemaService + +**Files to Delete:** +- `services/schemaCacheService.ts` (after merge) +- `services/schemaMappingService.ts` (after merge) + +### Step 3: Extract Repository Layer + +**Goal:** Abstract data access from business logic + +1. Create `repositories/ObjectRepository.ts` - Interface for object CRUD +2. Create `repositories/AttributeRepository.ts` - Interface for attribute access +3. Create `repositories/RelationRepository.ts` - Interface for relationships +4. Implement repositories using `NormalizedObjectStore` + +**Files to Create:** +- `repositories/ObjectRepository.ts` +- `repositories/AttributeRepository.ts` +- `repositories/RelationRepository.ts` +- `repositories/SchemaRepository.ts` + +**Files to Modify:** +- `services/normalizedCacheStore.ts` - Extract repository implementations + +### Step 4: Extract Store Implementations + +**Goal:** Separate storage implementations from business logic + +1. Extract EAV storage to `stores/NormalizedObjectStore.ts` +2. Extract object reconstruction to `stores/ObjectReconstructor.ts` +3. Extract relation extraction to `stores/RelationExtractor.ts` + +**Files to Create:** +- `stores/NormalizedObjectStore.ts` - EAV storage/retrieval +- `stores/ObjectReconstructor.ts` - TypeScript object reconstruction +- `stores/RelationExtractor.ts` - Relation extraction from objects + +**Files to Modify:** +- `services/normalizedCacheStore.ts` - Split into store classes + +### Step 5: Extract Query Builders + +**Goal:** Separate query construction from execution + +1. Move `queryBuilder.ts` to `queries/ObjectQueryBuilder.ts` +2. Extract filter building to `queries/FilterBuilder.ts` + +**Files to Create:** +- `queries/ObjectQueryBuilder.ts` +- `queries/FilterBuilder.ts` + +**Files to Modify:** +- `services/queryBuilder.ts` - Move to queries/ + +### Step 6: Extract CMDB Services + +**Goal:** Separate read/write/query concerns + +1. Create `services/cmdb/CmdbReadService.ts` - Read operations +2. Create `services/cmdb/CmdbWriteService.ts` - Write operations with conflict detection +3. Create `services/cmdb/CmdbQueryService.ts` - Query operations + +**Files to Create:** +- `services/cmdb/CmdbReadService.ts` +- `services/cmdb/CmdbWriteService.ts` +- `services/cmdb/CmdbQueryService.ts` + +**Files to Modify:** +- `services/cmdbService.ts` - Split into read/write/query services + +### Step 7: Extract Sync Service + +**Goal:** Separate sync orchestration from storage + +1. Create `domain/sync/SyncEngine.ts` - Sync business logic +2. Create `services/sync/SyncService.ts` - Sync orchestration + +**Files to Create:** +- `domain/sync/SyncEngine.ts` +- `services/sync/SyncService.ts` + +**Files to Modify:** +- `services/syncEngine.ts` - Split into domain + service + +### Step 8: Refactor API Routes + +**Goal:** Thin HTTP handlers delegating to services + +1. Create `api/handlers/` directory +2. Move route logic to handlers +3. Routes become thin wrappers around handlers + +**Files to Create:** +- `api/handlers/ObjectHandler.ts` +- `api/handlers/SchemaHandler.ts` +- `api/handlers/SyncHandler.ts` + +**Files to Modify:** +- `routes/applications.ts` - Extract handlers +- `routes/objects.ts` - Extract handlers +- `routes/cache.ts` - Extract handlers +- `routes/schema.ts` - Extract handlers + +### Step 9: Clean Up Legacy Code + +**Goal:** Remove deprecated files + +**Files to Delete:** +- `services/cacheStore.old.ts` +- Deprecated service files after migration complete + +### Step 10: Update Type Generation + +**Goal:** Simplify type generation workflow + +1. Consolidate type generation logic +2. Add automatic type generation on schema discovery +3. Update documentation + +**Files to Modify:** +- `scripts/generate-types-from-db.ts` - Enhance with auto-discovery +- `scripts/discover-schema.ts` - Auto-generate types after discovery + +--- + +## Explicit Deletion List + +### Phase 2 (After Migration Complete) + +1. **`backend/src/services/cacheStore.old.ts`** + - Reason: Legacy implementation, replaced by `normalizedCacheStore.ts` + - Deprecation date: TBD + +2. **`backend/src/services/jiraAssets.ts`** (after extracting business logic) + - Reason: API calls moved to infrastructure layer, business logic to services + - Replacement: `infrastructure/jira/JiraAssetsClient.ts` + `services/cmdb/Cmdb*Service.ts` + +3. **`backend/src/services/schemaCacheService.ts`** (after consolidation) + - Reason: Merged into `services/schema/SchemaService.ts` + +4. **`backend/src/services/schemaMappingService.ts`** (after consolidation) + - Reason: Merged into `services/schema/SchemaService.ts` + +5. **`backend/scripts/generate-schema.ts`** (if still present) + - Reason: Replaced by `generate-types-from-db.ts` + +### Notes + +- Keep old files until migration is complete and tested +- Mark as deprecated with `@deprecated` JSDoc comments +- Add migration guide for each deprecated file + +--- + +## API Payload Contract & Recursion Insights + +### Jira Assets API Payload Structure + +The Jira Assets API returns objects with the following nested structure: + +```typescript +interface JiraAssetsSearchResponse { + objectEntries: JiraAssetsObject[]; // Top-level array of objects + // ... pagination metadata +} + +interface JiraAssetsObject { + id: number; + objectKey: string; + label: string; + objectType: { + id: number; + name: string; + }; + attributes: JiraAssetsAttribute[]; // Array of attributes + updated?: string; + created?: string; +} + +interface JiraAssetsAttribute { + objectTypeAttributeId: number; + objectTypeAttribute?: { + id: number; + name: string; + }; + objectAttributeValues: Array<{ // Union type of value representations + value?: string; // For scalar values (text, number, etc.) + displayValue?: string; // Human-readable value + referencedObject?: { // For reference attributes + id: number; + objectKey: string; + label: string; + // ⚠️ CRITICAL: referencedObject may include attributes (level 2) + attributes?: JiraAssetsAttribute[]; // Recursive structure + }; + status?: { // For status attributes + name: string; + }; + // ... other type-specific fields + }>; +} +``` + +### Key Insights + +#### 1. Recursive Structure + +**Issue:** `referencedObject` may include `attributes[]` array (level 2 recursion). + +**Current Handling:** +- `jiraAssetsClient.ts` uses `includeAttributesDeep=2` parameter +- This causes referenced objects to include their attributes +- Referenced objects' attributes may themselves contain referenced objects (level 3, 4, etc.) +- **Cycles are possible** (Object A references Object B, Object B references Object A) + +**Current Code Location:** +- `backend/src/services/jiraAssetsClient.ts:222` - `includeAttributesDeep=2` +- `backend/src/services/jiraAssetsClient.ts:259-260` - Search with deep attributes +- `backend/src/services/jiraAssetsClient.ts:285-286` - POST search with deep attributes + +**Impact:** +- Response payloads can be very large (deeply nested) +- Memory usage increases with depth +- Parsing becomes more complex + +#### 2. Shallow Referenced Objects + +**Issue:** When `attributes[]` is absent on a shallow `referencedObject`, **do not wipe attributes**. + +**Current Behavior:** +- Some code paths may clear attributes if `attributes` is missing +- This is incorrect - absence of `attributes` array does not mean "no attributes" +- It simply means "attributes not included in this response" + +**Critical Rule:** +```typescript +// ❌ WRONG: Don't do this +if (!referencedObject.attributes) { + referencedObject.attributes = []; // This wipes existing attributes! +} + +// ✅ CORRECT: Preserve existing attributes if missing from response +if (referencedObject.attributes === undefined) { + // Don't modify - attributes simply not included in this response + // Keep any existing attributes that were previously loaded +} +``` + +**Code Locations to Review:** +- `backend/src/services/jiraAssetsClient.ts:parseObject()` - Object parsing +- `backend/src/services/jiraAssetsClient.ts:parseAttributeValue()` - Reference parsing +- `backend/src/services/normalizedCacheStore.ts:loadAttributeValues()` - Reference reconstruction + +#### 3. Attribute Values Union Type + +**Issue:** `objectAttributeValues` is a union type - different value representations based on attribute type. + +**Value Types:** +- Scalar (text, number, boolean): `{ value?: string, displayValue?: string }` +- Reference: `{ referencedObject?: { id, objectKey, label, attributes? } }` +- Status: `{ status?: { name: string } }` +- Date/Datetime: `{ value?: string }` (ISO string) + +**Current Handling:** +- `jiraAssetsClient.ts:parseAttributeValue()` uses switch on `attrDef.type` +- Different parsing logic for each type +- Reference types extract `referencedObject`, others use `value` or `displayValue` + +**Code Location:** +- `backend/src/services/jiraAssetsClient.ts:521-628` - `parseAttributeValue()` method + +#### 4. Cycles and Recursion Depth + +**Issue:** Recursive references can create cycles. + +**Examples:** +- Application A references Team X +- Team X references Application A (via some attribute) +- This creates a cycle at depth 2 + +**Current Handling:** +- No explicit cycle detection +- `includeAttributesDeep=2` limits depth but doesn't prevent cycles +- Potential for infinite loops during reconstruction + +**Recommendation:** +- Add cycle detection during object reconstruction +- Use visited set to track processed object IDs +- Limit recursion depth explicitly (not just via API parameter) + +**Code Locations:** +- `backend/src/services/normalizedCacheStore.ts:loadAttributeValues()` - Reference resolution +- `backend/src/services/normalizedCacheStore.ts:reconstructObject()` - Object reconstruction + +### Refactoring Considerations + +1. **Create dedicated parser module** for handling recursive payloads +2. **Add cycle detection** utility +3. **Separate shallow vs deep parsing** logic +4. **Preserve attribute state** when attributes array is absent +5. **Document recursion depth limits** clearly + +--- + +## Appendix: Module Dependency Graph + +``` +┌─────────────────────────────────────────────────────────┐ +│ API Routes │ +│ (applications.ts, objects.ts, cache.ts, schema.ts) │ +└────────────┬────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Application Services │ +│ (cmdbService.ts, dataService.ts) │ +└────────────┬────────────────────────────────────────────┘ + │ + ┌────────┴────────┐ + ▼ ▼ +┌────────────┐ ┌──────────────────────────────┐ +│ Sync │ │ Normalized Cache Store │ +│ Engine │ │ (EAV Pattern) │ +└─────┬──────┘ └──────────┬───────────────────┘ + │ │ + │ ▼ + │ ┌─────────────────┐ + │ │ Query Builder │ + │ └─────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Jira Assets Client │ +│ (jiraAssetsClient.ts, jiraAssets.ts) │ +└────────────┬────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Schema Services │ +│ (schemaDiscovery, schemaCache, etc.) │ +└─────────────────────────────────────────┘ +``` + +--- + +## Next Steps (Phase 2) + +1. Review and approve this plan +2. Create detailed task breakdown for each migration step +3. Set up feature branch for refactoring +4. Implement changes incrementally with tests +5. Update documentation as we go + +--- + +**End of Phase 1 - Analysis Document** diff --git a/frontend/index.html b/frontend/index.html index fa3a87a..5522d61 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - CMDB Analyse Tool - Zuyderland + CMDB Insight - Zuyderland
diff --git a/frontend/package.json b/frontend/package.json index 9fe1226..fcd082c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "zira-frontend", + "name": "cmdb-insight-frontend", "version": "1.0.0", "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0ea3c08..096bd57 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,7 +10,6 @@ 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 TechnicalDebtHeatmap from './components/TechnicalDebtHeatmap'; import LifecyclePipeline from './components/LifecyclePipeline'; import DataCompletenessScore from './components/DataCompletenessScore'; @@ -21,6 +20,9 @@ import FTECalculator from './components/FTECalculator'; import DataCompletenessConfig from './components/DataCompletenessConfig'; import BIASyncDashboard from './components/BIASyncDashboard'; import BusinessImportanceComparison from './components/BusinessImportanceComparison'; +import DataValidationDashboard from './components/DataValidationDashboard'; +import SchemaConfigurationSettings from './components/SchemaConfigurationSettings'; +import ArchitectureDebugPage from './components/ArchitectureDebugPage'; import Login from './components/Login'; import ForgotPassword from './components/ForgotPassword'; import ResetPassword from './components/ResetPassword'; @@ -29,6 +31,7 @@ import ProtectedRoute from './components/ProtectedRoute'; import UserManagement from './components/UserManagement'; import RoleManagement from './components/RoleManagement'; import ProfileSettings from './components/ProfileSettings'; +import { ToastContainerComponent } from './components/Toast'; import { useAuthStore } from './stores/authStore'; // Module-level singleton to prevent duplicate initialization across StrictMode remounts @@ -237,14 +240,15 @@ function AppContent() { const hasPermission = useAuthStore((state) => state.hasPermission); const config = useAuthStore((state) => state.config); - // Navigation structure + // Navigation structure - Logical flow for CMDB setup and data management + // Flow: 1. Setup (Schema Discovery → Configuration → Sync) → 2. Data (Model → Validation) → 3. Application Component → 4. Reports + const appComponentsDropdown: NavDropdown = { label: 'Application Component', basePath: '/application', items: [ { path: '/app-components', label: 'Dashboard', exact: true, requiredPermission: 'search' }, { path: '/application/overview', label: 'Overzicht', exact: false, requiredPermission: 'search' }, - { path: '/application/fte-calculator', label: 'FTE Calculator', exact: true, requiredPermission: 'search' }, ], }; @@ -270,6 +274,7 @@ function AppContent() { basePath: '/apps', items: [ { path: '/apps/bia-sync', label: 'BIA Sync', exact: true, requiredPermission: 'search' }, + { path: '/apps/fte-calculator', label: 'FTE Calculator', exact: true, requiredPermission: 'search' }, ], }; @@ -277,9 +282,8 @@ function AppContent() { label: 'Instellingen', basePath: '/settings', items: [ - { path: '/settings/fte-config', label: 'FTE Config', exact: true, requiredPermission: 'manage_settings' }, - { path: '/settings/data-model', label: 'Datamodel', exact: true, requiredPermission: 'manage_settings' }, { path: '/settings/data-completeness-config', label: 'Data Completeness Config', exact: true, requiredPermission: 'manage_settings' }, + { path: '/settings/fte-config', label: 'FTE Config', exact: true, requiredPermission: 'manage_settings' }, ], }; @@ -287,16 +291,23 @@ function AppContent() { label: 'Beheer', basePath: '/admin', items: [ + { path: '/settings/schema-configuration', label: 'Schema Configuratie & Datamodel', exact: true, requiredPermission: 'manage_settings' }, + { path: '/settings/data-validation', label: 'Data Validatie', exact: true, requiredPermission: 'manage_settings' }, { path: '/admin/users', label: 'Gebruikers', exact: true, requiredPermission: 'manage_users' }, { path: '/admin/roles', label: 'Rollen', exact: true, requiredPermission: 'manage_roles' }, + { path: '/admin/debug', label: 'Architecture Debug', exact: true, requiredPermission: 'admin' }, ], }; const isAppComponentsActive = location.pathname.startsWith('/app-components') || location.pathname.startsWith('/application'); const isReportsActive = location.pathname.startsWith('/reports'); - const isSettingsActive = location.pathname.startsWith('/settings'); + // Settings is active for /settings paths EXCEPT admin items (schema-configuration, data-model, data-validation) + const isSettingsActive = location.pathname.startsWith('/settings') + && !location.pathname.startsWith('/settings/schema-configuration') + && !location.pathname.startsWith('/settings/data-model') + && !location.pathname.startsWith('/settings/data-validation'); const isAppsActive = location.pathname.startsWith('/apps'); - const isAdminActive = location.pathname.startsWith('/admin'); + const isAdminActive = location.pathname.startsWith('/admin') || location.pathname.startsWith('/settings/schema-configuration') || location.pathname.startsWith('/settings/data-model') || location.pathname.startsWith('/settings/data-validation'); return (
@@ -319,16 +330,16 @@ function AppContent() { {/* Application Component Dropdown */} - {/* Apps Dropdown */} - - {/* Reports Dropdown */} - {/* Settings Dropdown */} + {/* Apps Dropdown */} + + + {/* Settings Dropdown - Advanced configuration */} - {/* Admin Dropdown */} + {/* Admin Dropdown - Setup (Schema Config + Data Model + Data Validation) + Administration */}
@@ -338,6 +349,9 @@ function AppContent() { + {/* Toast Notifications */} + + {/* Main content */}
@@ -346,7 +360,6 @@ function AppContent() { {/* Application routes (new structure) - specific routes first, then dynamic */} } /> - } /> } /> } /> @@ -367,10 +380,13 @@ function AppContent() { {/* Apps routes */} } /> + } /> {/* Settings routes */} + } /> } /> - } /> + } /> + } /> } /> } /> {/* Legacy redirects for old routes */} @@ -379,6 +395,7 @@ function AppContent() { {/* Admin routes */} } /> } /> + } /> {/* Legacy redirects for bookmarks - redirect old paths to new ones */} } /> @@ -386,7 +403,8 @@ function AppContent() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/ApplicationInfo.tsx b/frontend/src/components/ApplicationInfo.tsx index 0653b96..331373c 100644 --- a/frontend/src/components/ApplicationInfo.tsx +++ b/frontend/src/components/ApplicationInfo.tsx @@ -5,6 +5,7 @@ import { getApplicationById, getConfig, getRelatedObjects, + refreshApplication, RelatedObject, } from '../services/api'; import { StatusBadge, BusinessImportanceBadge } from './ApplicationList'; @@ -133,6 +134,8 @@ export default function ApplicationInfo() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [jiraHost, setJiraHost] = useState(''); + const [refreshing, setRefreshing] = useState(false); + const [refreshMessage, setRefreshMessage] = useState(null); // Use centralized effort calculation hook const { calculatedFte, breakdown: effortBreakdown } = useEffortCalculation({ @@ -244,6 +247,44 @@ export default function ApplicationInfo() { setGovernanceExpanded(prev => !prev); }; + const handleRefresh = async () => { + if (!id || refreshing) return; + + setRefreshing(true); + setRefreshMessage(null); + + try { + const result = await refreshApplication(id); + setRefreshMessage('Applicatie succesvol gesynchroniseerd vanuit Jira'); + + // Reload the application data after a short delay to show the success message + setTimeout(async () => { + try { + const refreshedApp = await getApplicationById(id); + setApplication(refreshedApp); + // Clear success message after 3 seconds + setTimeout(() => { + setRefreshMessage(null); + }, 3000); + } catch (err) { + setRefreshMessage('Applicatie gesynchroniseerd, maar herladen mislukt. Ververs de pagina.'); + // Clear error message after 5 seconds + setTimeout(() => { + setRefreshMessage(null); + }, 5000); + } + }, 1000); + } catch (err) { + setRefreshMessage(err instanceof Error ? err.message : 'Synchronisatie mislukt'); + // Clear error message after 5 seconds + setTimeout(() => { + setRefreshMessage(null); + }, 5000); + } finally { + setRefreshing(false); + } + }; + if (loading) { return (
@@ -391,6 +432,24 @@ export default function ApplicationInfo() { )} + + +
@@ -402,6 +461,34 @@ export default function ApplicationInfo() { + {/* Refresh message */} + {refreshMessage && ( +
+
+ {refreshMessage.includes('succesvol') || refreshMessage.includes('gesynchroniseerd') ? ( + + + + ) : ( + + + + )} + + {refreshMessage} + +
+
+ )} + {/* Reference warning - only show if reference is truly empty */} {(() => { const refValue = application.reference; diff --git a/frontend/src/components/ApplicationList.tsx b/frontend/src/components/ApplicationList.tsx index f049bab..f15cf48 100644 --- a/frontend/src/components/ApplicationList.tsx +++ b/frontend/src/components/ApplicationList.tsx @@ -1,10 +1,11 @@ import { useEffect, useState, useCallback } from 'react'; import { useNavigate, useSearchParams, Link } from 'react-router-dom'; import { clsx } from 'clsx'; -import { searchApplications, getReferenceData } from '../services/api'; +import { searchApplications, getReferenceData, triggerSync } from '../services/api'; import { useSearchStore } from '../stores/searchStore'; import { useNavigationStore } from '../stores/navigationStore'; import type { ApplicationListItem, SearchResult, ReferenceValue, ApplicationStatus } from '../types'; +import PageHeader from './PageHeader'; const ALL_STATUSES: ApplicationStatus[] = [ 'In Production', @@ -47,6 +48,7 @@ export default function ApplicationList() { const [businessImportanceOptions, setBusinessImportanceOptions] = useState([]); const [applicationSubteams, setApplicationSubteams] = useState([]); const [showFilters, setShowFilters] = useState(true); + const [syncing, setSyncing] = useState(false); // Sync URL params with store on mount useEffect(() => { @@ -96,12 +98,23 @@ export default function ApplicationList() { async function loadReferenceData() { try { const data = await getReferenceData(); - setOrganisations(data.organisations); - setHostingTypes(data.hostingTypes); + console.log('Loaded reference data:', { + organisations: data.organisations?.length || 0, + hostingTypes: data.hostingTypes?.length || 0, + businessImportance: data.businessImportance?.length || 0, + applicationSubteams: data.applicationSubteams?.length || 0, + }); + setOrganisations(data.organisations || []); + setHostingTypes(data.hostingTypes || []); setBusinessImportanceOptions(data.businessImportance || []); setApplicationSubteams(data.applicationSubteams || []); } catch (err) { console.error('Failed to load reference data', err); + // Set empty arrays on error so UI doesn't break + setOrganisations([]); + setHostingTypes([]); + setBusinessImportanceOptions([]); + setApplicationSubteams([]); } } loadReferenceData(); @@ -141,21 +154,58 @@ export default function ApplicationList() { } }; + const handleSync = async () => { + if (syncing) return; + setSyncing(true); + setError(null); + try { + await triggerSync(); + // Start polling for results - sync runs in background + const pollInterval = setInterval(async () => { + try { + await fetchApplications(); + // Stop polling if we have results + if (result && result.totalCount > 0) { + clearInterval(pollInterval); + setSyncing(false); + } + } catch (err) { + console.error('Failed to poll for results', err); + } + }, 3000); // Poll every 3 seconds + + // Stop polling after 5 minutes max + setTimeout(() => { + clearInterval(pollInterval); + setSyncing(false); + }, 5 * 60 * 1000); + } catch (err) { + console.error('Failed to trigger sync', err); + setError('Synchronisatie mislukt. Controleer de server logs.'); + setSyncing(false); + } + }; + return (
{/* Page header */} -
-
-

Applicaties

-

Zoek en classificeer applicatiecomponenten

-
- -
+ + + + } + actions={ + + } + /> {/* Search and filters */}
@@ -288,13 +338,18 @@ export default function ApplicationList() { value={filters.organisation || ''} onChange={(e) => setOrganisation(e.target.value || undefined)} className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + disabled={organisations.length === 0 && loading} > - {organisations.map((org) => ( - - ))} + {organisations.length === 0 && !loading ? ( + + ) : ( + organisations.map((org) => ( + + )) + )}
@@ -304,13 +359,18 @@ export default function ApplicationList() { value={filters.hostingType || ''} onChange={(e) => setHostingType(e.target.value || undefined)} className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + disabled={hostingTypes.length === 0 && loading} > - {hostingTypes.map((type) => ( - - ))} + {hostingTypes.length === 0 && !loading ? ( + + ) : ( + hostingTypes.map((type) => ( + + )) + )}
@@ -320,13 +380,18 @@ export default function ApplicationList() { value={filters.businessImportance || ''} onChange={(e) => setBusinessImportance(e.target.value || undefined)} className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + disabled={businessImportanceOptions.length === 0 && loading} > - {businessImportanceOptions.map((importance) => ( - - ))} + {businessImportanceOptions.length === 0 && !loading ? ( + + ) : ( + businessImportanceOptions.map((importance) => ( + + )) + )} @@ -400,7 +465,50 @@ export default function ApplicationList() { - {result?.applications.map((app, index) => ( + {result && result.applications.length === 0 ? ( + + +
+ + + +
+

Geen resultaten gevonden

+

+ {result.totalCount === 0 + ? 'De cache is leeg. Voer een volledige synchronisatie uit om applicaties te laden.' + : 'Probeer andere zoektermen of filters.'} +

+
+ {result.totalCount === 0 && ( + + )} +
+ + + ) : ( + result?.applications.map((app, index) => ( - ))} + )) + )} diff --git a/frontend/src/components/ArchitectureDebugPage.tsx b/frontend/src/components/ArchitectureDebugPage.tsx new file mode 100644 index 0000000..2d63ae3 --- /dev/null +++ b/frontend/src/components/ArchitectureDebugPage.tsx @@ -0,0 +1,876 @@ +/** + * ArchitectureDebugPage - Debug page for testing refactored architecture + * + * Tests: + * A - Level2 recursion: referencedObject with attributes is stored + * B - Disabled type: NOT full sync, but reference-only caching works + * C - Attribute wipe rule: shallow referencedObject doesn't delete existing values + * D - QueryService reconstruction: DB → TS object is correct + * E - Write-through: update to Jira + immediate DB update + */ + +import { useState, useEffect } from 'react'; +import { getConfiguredObjectTypes } from '../services/api'; +import PageHeader from './PageHeader'; + +interface TestResult { + test: string; + status: 'pending' | 'running' | 'success' | 'error'; + message?: string; + data?: any; + sqlQueries?: Array<{ sql: string; result: any }>; + timestamp?: string; +} + +export default function ArchitectureDebugPage() { + const [testResults, setTestResults] = useState>({}); + const [loading, setLoading] = useState>({}); + const [enabledTypes, setEnabledTypes] = useState([]); + const [loadingEnabledTypes, setLoadingEnabledTypes] = useState(true); + const [inputs, setInputs] = useState({ + typeName: 'ApplicationComponent', + objectKey: '', + referencedObjectKey: '', + disabledTypeName: 'HostingType', + updateField: 'description', + updateValue: 'Updated via Debug Page', + }); + + // Load enabled object types on mount + useEffect(() => { + const loadEnabledTypes = async () => { + try { + setLoadingEnabledTypes(true); + const config = await getConfiguredObjectTypes(); + const enabled = config.schemas.flatMap(s => + s.objectTypes.filter(ot => ot.enabled).map(ot => ot.objectTypeName) + ); + setEnabledTypes(enabled); + } catch (error) { + console.error('Failed to load enabled types:', error); + } finally { + setLoadingEnabledTypes(false); + } + }; + loadEnabledTypes(); + }, []); + + const updateTestResult = (testId: string, updates: Partial) => { + setTestResults(prev => ({ + ...prev, + [testId]: { + ...prev[testId], + ...updates, + timestamp: new Date().toISOString(), + } as TestResult, + })); + }; + + const executeSqlQuery = async (sql: string, params: any[] = []): Promise => { + try { + const response = await fetch('/api/v2/debug/query', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ sql, params }), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(errorData.error || `HTTP ${response.status}`); + } + const data = await response.json(); + return data.result || []; + } catch (error) { + console.error('SQL query failed:', error); + throw error; + } + }; + + // Test A: Level2 recursion + const runTestA = async () => { + const testId = 'testA'; + setLoading(prev => ({ ...prev, [testId]: true })); + updateTestResult(testId, { test: 'Test A: Level2 Recursion', status: 'running' }); + + try { + // Step 1: Sync object type + updateTestResult(testId, { message: `Syncing object type "${inputs.typeName}"...` }); + const syncResponse = await fetch(`/api/v2/sync/objects/${inputs.typeName}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }); + if (!syncResponse.ok) { + const errorData = await syncResponse.json().catch(() => ({ error: `HTTP ${syncResponse.status}: Sync failed` })); + const errorMessage = errorData.error || `HTTP ${syncResponse.status}: Sync failed`; + + // Extract enabled types from error details if available + let enabledTypesHint = ''; + if (errorData.details?.enabledTypeNames && errorData.details.enabledTypeNames.length > 0) { + enabledTypesHint = ` Currently enabled: ${errorData.details.enabledTypeNames.join(', ')}.`; + } + + // Provide helpful hints for common errors + let hint = ''; + if (errorMessage.includes('not enabled')) { + hint = ` Tip: Go to Schema Configuration settings and enable "${inputs.typeName}".${enabledTypesHint}`; + } else if (errorMessage.includes('token')) { + hint = ' Tip: Check if JIRA_SERVICE_ACCOUNT_TOKEN is configured in .env or if you have a Personal Access Token configured.'; + } else if (errorMessage.includes('404') || errorMessage.includes('not found')) { + hint = ' Tip: The object type may not exist in your Jira Assets instance. Check the type name spelling.'; + } + + throw new Error(errorMessage + hint); + } + const syncResult = await syncResponse.json(); + + // Check for errors in sync result + if (syncResult.hasErrors && syncResult.errors && syncResult.errors.length > 0) { + const errorMessages = syncResult.errors.map((e: any) => `${e.objectId}: ${e.error}`).join('; '); + console.warn(`Sync completed with errors: ${errorMessages}`); + } + // Check if sync had errors (even if not marked as hasErrors) + if (syncResult.errors && syncResult.errors.length > 0) { + const errorMessages = syncResult.errors.map((e: any) => e.error || e.message || 'Unknown error').join('; '); + + // Check if it's a network/Jira API error + if (errorMessages.includes('Failed to fetch') || errorMessages.includes('fetch failed') || errorMessages.includes('network')) { + throw new Error(`Network error while syncing: ${errorMessages}. This usually means: - Jira host URL is incorrect or unreachable - Network connectivity issues - Jira API authentication failed. Check your Jira PAT token in Profile Settings and verify the JIRA_HOST environment variable.`); + } + + throw new Error(`Sync failed: ${errorMessages}. Check backend logs for details.`); + } + + if (syncResult.objectsCached === 0 && syncResult.objectsProcessed === 0) { + throw new Error('Sync completed but no objects were processed. This could mean:\n- The object type has no objects in Jira Assets\n- The IQL search returned no results\n- Check if the object type name is correct'); + } + + // Step 2: Get a sample object to check + updateTestResult(testId, { message: 'Fetching sample objects...' }); + const objectsResponse = await fetch(`/api/v2/objects/${inputs.typeName}?limit=10`, { + credentials: 'include', + }); + if (!objectsResponse.ok) { + const errorData = await objectsResponse.json().catch(() => ({ error: `HTTP ${objectsResponse.status}: Failed to fetch objects` })); + const errorMessage = errorData.error || `HTTP ${objectsResponse.status}: Failed to fetch objects`; + throw new Error(`${errorMessage}. Sync processed ${syncResult.objectsProcessed || 0} objects. If objects were synced, they may not be queryable yet.`); + } + const objectsData = await objectsResponse.json(); + const sampleObject = objectsData.objects?.[0]; + if (!sampleObject) { + throw new Error(`No objects found after sync. Sync processed ${syncResult.objectsProcessed || 0} objects, cached ${syncResult.objectsCached || 0}. The objects may not be queryable yet or the object type has no objects in Jira Assets.`); + } + + // Step 3: Find a referenced object from the sample + updateTestResult(testId, { message: 'Looking for referenced objects in sample...' }); + const referencedObjectKey = inputs.referencedObjectKey || (() => { + // Try to find a reference in the object + const references: string[] = []; + for (const [key, value] of Object.entries(sampleObject)) { + if (value && typeof value === 'object' && ('objectKey' in value || 'key' in value)) { + const objKey = (value as any).objectKey || (value as any).key; + if (objKey) references.push(objKey); + } + if (Array.isArray(value) && value.length > 0) { + for (const item of value) { + if (item && typeof item === 'object' && ('objectKey' in item || 'key' in item)) { + const objKey = item.objectKey || item.key; + if (objKey) references.push(objKey); + } + } + } + } + return references.length > 0 ? references[0] : null; + })(); + + if (!referencedObjectKey) { + const sampleObjectKeys = Object.keys(sampleObject).join(', '); + throw new Error(`No referenced object found in sample object. Object fields: ${sampleObjectKeys}. Please provide a referencedObjectKey manually if the object has references.`); + } + + updateTestResult(testId, { message: `Found referenced object: ${referencedObjectKey}` }); + + // Step 4: Run SQL checks + updateTestResult(testId, { message: 'Running database checks...' }); + + const sqlQueries: Array<{ sql: string; result: any }> = []; + + // Check 1: Both objects exist + const sampleObjectKey = sampleObject.objectKey || sampleObject.key || sampleObject.id; + if (!sampleObjectKey) { + throw new Error(`Sample object has no objectKey. Object structure: ${JSON.stringify(sampleObject)}`); + } + + const check1Sql = `SELECT id, object_key, object_type_name, label FROM objects WHERE object_key IN (?, ?)`; + const check1Result = await executeSqlQuery(check1Sql, [sampleObjectKey, referencedObjectKey]); + sqlQueries.push({ sql: check1Sql, result: check1Result }); + + // Check 2: Referenced object has attribute values + // Handle both snake_case and camelCase column names + const refObj = check1Result.find((o: any) => + (o.object_key || o.objectKey) === referencedObjectKey + ); + + if (!refObj) { + const foundKeys = check1Result.map((o: any) => (o.object_key || o.objectKey)).filter(Boolean); + throw new Error(`Referenced object "${referencedObjectKey}" not found in database. Found objects: ${foundKeys.join(', ') || 'none'}. The referenced object may not have been cached during sync.`); + } + + const check2Sql = `SELECT COUNT(*) as count FROM attribute_values WHERE object_id = ?`; + const check2Result = await executeSqlQuery(check2Sql, [refObj.id]); + sqlQueries.push({ sql: check2Sql, result: check2Result }); + + const attrCount = check2Result[0]?.count || (check2Result[0]?.count === 0 ? 0 : null); + + // Check 3: Get sample attribute values to verify they exist + const check3Sql = `SELECT av.*, a.field_name, a.attr_type + FROM attribute_values av + JOIN attributes a ON a.id = av.attribute_id + WHERE av.object_id = ? + LIMIT 5`; + const check3Result = await executeSqlQuery(check3Sql, [refObj.id]); + sqlQueries.push({ sql: check3Sql, result: check3Result }); + + updateTestResult(testId, { + status: 'success', + message: `✅ Sync completed successfully. Found ${syncResult.objectsCached || 0} objects. Referenced object "${referencedObjectKey}" exists with ${attrCount || 0} attribute values.`, + data: { + syncResult: { + objectsProcessed: syncResult.objectsProcessed, + objectsCached: syncResult.objectsCached, + relationsExtracted: syncResult.relationsExtracted, + }, + sampleObject: { objectKey: sampleObjectKey, label: sampleObject.label || sampleObject.name }, + referencedObject: { + objectKey: referencedObjectKey, + id: refObj.id, + typeName: refObj.object_type_name || refObj.objectTypeName, + label: refObj.label, + attributeValueCount: attrCount, + }, + }, + sqlQueries, + }); + } catch (error) { + updateTestResult(testId, { + status: 'error', + message: error instanceof Error ? error.message : 'Test failed', + }); + } finally { + setLoading(prev => ({ ...prev, [testId]: false })); + } + }; + + // Test B: Disabled type reference-only caching + const runTestB = async () => { + const testId = 'testB'; + setLoading(prev => ({ ...prev, [testId]: true })); + updateTestResult(testId, { test: 'Test B: Disabled Type Reference-Only', status: 'running' }); + + try { + const disabledType = inputs.disabledTypeName; + + // Step 1: Check initial count + updateTestResult(testId, { message: 'Checking initial object count...' }); + const initialCountResult = await executeSqlQuery( + `SELECT COUNT(*) as count FROM objects WHERE object_type_name = ?`, + [disabledType] + ); + const initialCount = initialCountResult[0]?.count || 0; + + // Step 2: Sync an enabled type that references the disabled type + updateTestResult(testId, { message: `Syncing ${inputs.typeName} (should cache ${disabledType} references)...` }); + const syncResponse = await fetch(`/api/v2/sync/objects/${inputs.typeName}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }); + if (!syncResponse.ok) throw new Error('Sync failed'); + const syncResult = await syncResponse.json(); + + // Step 3: Check final count (should not have increased much) + updateTestResult(testId, { message: 'Checking final object count...' }); + const finalCountResult = await executeSqlQuery( + `SELECT COUNT(*) as count FROM objects WHERE object_type_name = ?`, + [disabledType] + ); + const finalCount = finalCountResult[0]?.count || 0; + + // Step 4: Check relations + const relationsResult = await executeSqlQuery( + `SELECT COUNT(*) as count + FROM object_relations r + JOIN objects t ON t.id = r.target_id + WHERE t.object_type_name = ?`, + [disabledType] + ); + const relationCount = relationsResult[0]?.count || 0; + + const sqlQueries = [ + { sql: 'Initial count query', result: initialCountResult }, + { sql: 'Final count query', result: finalCountResult }, + { sql: 'Relations query', result: relationsResult }, + ]; + + updateTestResult(testId, { + status: 'success', + message: `Disabled type ${disabledType}: ${initialCount} → ${finalCount} objects. ${relationCount} relations found.`, + data: { + initialCount, + finalCount, + relationCount, + delta: finalCount - initialCount, + }, + sqlQueries, + }); + } catch (error) { + updateTestResult(testId, { + status: 'error', + message: error instanceof Error ? error.message : 'Test failed', + }); + } finally { + setLoading(prev => ({ ...prev, [testId]: false })); + } + }; + + // Test C: Attribute wipe rule + const runTestC = async () => { + const testId = 'testC'; + setLoading(prev => ({ ...prev, [testId]: true })); + updateTestResult(testId, { test: 'Test C: Attribute Wipe Rule', status: 'running' }); + + try { + if (!inputs.referencedObjectKey) { + throw new Error('Please provide a referencedObjectKey to test'); + } + + // Step 1: Get initial attribute count + updateTestResult(testId, { message: 'Checking initial attribute values...' }); + const objInfoResponse = await fetch(`/api/v2/debug/objects?objectKey=${inputs.referencedObjectKey}`, { + credentials: 'include', + }); + if (!objInfoResponse.ok) throw new Error('Failed to get object info'); + const objInfo = await objInfoResponse.json(); + const initialAttrCount = objInfo.attributeValueCount || 0; + + if (initialAttrCount === 0) { + throw new Error('Object has no attributes - sync it first with level2 expansion'); + } + + // Step 2: Sync a parent object that references this one (should come as shallow) + updateTestResult(testId, { message: 'Syncing parent object (referenced object may come as shallow)...' }); + const syncResponse = await fetch(`/api/v2/sync/objects/${inputs.typeName}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }); + if (!syncResponse.ok) throw new Error('Sync failed'); + + // Step 3: Check final attribute count (should be same or greater) + updateTestResult(testId, { message: 'Checking final attribute values...' }); + const finalObjInfoResponse = await fetch(`/api/v2/debug/objects?objectKey=${inputs.referencedObjectKey}`, { + credentials: 'include', + }); + if (!finalObjInfoResponse.ok) throw new Error('Failed to get final object info'); + const finalObjInfo = await finalObjInfoResponse.json(); + const finalAttrCount = finalObjInfo.attributeValueCount || 0; + + const sqlQueries = [ + { sql: 'Initial attribute count', result: [{ count: initialAttrCount }] }, + { sql: 'Final attribute count', result: [{ count: finalAttrCount }] }, + ]; + + const success = finalAttrCount >= initialAttrCount; + + updateTestResult(testId, { + status: success ? 'success' : 'error', + message: success + ? `✅ Attributes preserved: ${initialAttrCount} → ${finalAttrCount}` + : `❌ Attributes were wiped: ${initialAttrCount} → ${finalAttrCount}`, + data: { + initialAttrCount, + finalAttrCount, + preserved: success, + }, + sqlQueries, + }); + } catch (error) { + updateTestResult(testId, { + status: 'error', + message: error instanceof Error ? error.message : 'Test failed', + }); + } finally { + setLoading(prev => ({ ...prev, [testId]: false })); + } + }; + + // Test D: QueryService reconstruction + const runTestD = async () => { + const testId = 'testD'; + setLoading(prev => ({ ...prev, [testId]: true })); + updateTestResult(testId, { test: 'Test D: QueryService Reconstruction', status: 'running' }); + + try { + if (!inputs.objectKey) { + throw new Error('Please provide an objectKey to test'); + } + + // Step 1: Get object from DB via QueryService + updateTestResult(testId, { message: 'Fetching object via QueryService...' }); + const objectResponse = await fetch(`/api/v2/objects/${inputs.typeName}/${inputs.objectKey}`, { + credentials: 'include', + }); + if (!objectResponse.ok) throw new Error('Failed to fetch object'); + const object = await objectResponse.json(); + + // Step 2: Get raw DB data for comparison + updateTestResult(testId, { message: 'Fetching raw DB data...' }); + const objInfoResponse = await fetch(`/api/v2/debug/objects?objectKey=${inputs.objectKey}`, { + credentials: 'include', + }); + if (!objInfoResponse.ok) throw new Error('Failed to get object info'); + const objInfo = await objInfoResponse.json(); + + // Step 3: Get attribute values from DB + const attrValuesResult = await executeSqlQuery( + `SELECT av.*, a.field_name, a.attr_type, a.is_multiple + FROM attribute_values av + JOIN attributes a ON a.id = av.attribute_id + WHERE av.object_id = ? + ORDER BY a.field_name, av.array_index`, + [objInfo.object.id] + ); + + // Step 4: Verify reconstruction + const fieldCount = new Set(attrValuesResult.map((av: any) => av.field_name)).size; + const objectFieldCount = Object.keys(object).filter(k => !k.startsWith('_') && k !== 'id' && k !== 'objectKey' && k !== 'label').length; + + const sqlQueries = [ + { sql: 'Attribute values query', result: attrValuesResult }, + ]; + + updateTestResult(testId, { + status: 'success', + message: `Object reconstructed: ${fieldCount} fields from DB, ${objectFieldCount} fields in object`, + data: { + object, + dbFields: fieldCount, + objectFields: objectFieldCount, + attributeValues: attrValuesResult.length, + }, + sqlQueries, + }); + } catch (error) { + updateTestResult(testId, { + status: 'error', + message: error instanceof Error ? error.message : 'Test failed', + }); + } finally { + setLoading(prev => ({ ...prev, [testId]: false })); + } + }; + + // Test E: Write-through + const runTestE = async () => { + const testId = 'testE'; + setLoading(prev => ({ ...prev, [testId]: true })); + updateTestResult(testId, { test: 'Test E: Write-Through Update', status: 'running' }); + + try { + if (!inputs.objectKey) { + throw new Error('Please provide an objectKey to test'); + } + + // Step 1: Get initial object state + updateTestResult(testId, { message: 'Fetching initial object state...' }); + const initialResponse = await fetch(`/api/v2/objects/${inputs.typeName}/${inputs.objectKey}`, { + credentials: 'include', + }); + if (!initialResponse.ok) throw new Error('Failed to fetch initial object'); + const initialObject = await initialResponse.json(); + + // Step 2: Update via write-through + updateTestResult(testId, { message: 'Updating object via write-through...' }); + const updatePayload: Record = {}; + updatePayload[inputs.updateField] = inputs.updateValue; + + const updateResponse = await fetch(`/api/v2/objects/${inputs.typeName}/${inputs.objectKey}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(updatePayload), + }); + if (!updateResponse.ok) { + const error = await updateResponse.json(); + throw new Error(error.error || 'Update failed'); + } + const updateResult = await updateResponse.json(); + + // Step 3: Immediately check DB (without refresh) + updateTestResult(testId, { message: 'Checking DB state immediately...' }); + const dbCheckResponse = await fetch(`/api/v2/objects/${inputs.typeName}/${inputs.objectKey}`, { + credentials: 'include', + }); + if (!dbCheckResponse.ok) throw new Error('Failed to fetch updated object'); + const dbObject = await dbCheckResponse.json(); + + // Step 4: Check attribute value in DB + const attrCheckResult = await executeSqlQuery( + `SELECT av.*, a.field_name + FROM attribute_values av + JOIN attributes a ON a.id = av.attribute_id + JOIN objects o ON o.id = av.object_id + WHERE o.object_key = ? AND a.field_name = ?`, + [inputs.objectKey, inputs.updateField] + ); + + const sqlQueries = [ + { sql: 'Attribute value check', result: attrCheckResult }, + ]; + + const dbValue = dbObject[inputs.updateField]; + const updated = dbValue === inputs.updateValue || (typeof dbValue === 'object' && dbValue?.label === inputs.updateValue); + + updateTestResult(testId, { + status: updated ? 'success' : 'error', + message: updated + ? `✅ Write-through successful: DB updated immediately` + : `❌ Write-through failed: DB value doesn't match`, + data: { + initialValue: initialObject[inputs.updateField], + expectedValue: inputs.updateValue, + dbValue, + updated, + }, + sqlQueries, + }); + } catch (error) { + updateTestResult(testId, { + status: 'error', + message: error instanceof Error ? error.message : 'Test failed', + }); + } finally { + setLoading(prev => ({ ...prev, [testId]: false })); + } + }; + + return ( +
+ + + + + } + /> + + {/* Input Form */} +
+

Test Parameters

+
+
+ +
+ setInputs({ ...inputs, typeName: e.target.value })} + className="flex-1 border border-gray-300 rounded-md px-3 py-2" + placeholder="ApplicationComponent" + list="enabled-types-list" + /> + + {enabledTypes.map(type => ( + +
+ {!loadingEnabledTypes && enabledTypes.length > 0 && ( +

+ Enabled types: {enabledTypes.join(', ')} + {!enabledTypes.includes(inputs.typeName) && inputs.typeName && ( + + ⚠ "{inputs.typeName}" is not enabled + + )} +

+ )} + {!loadingEnabledTypes && enabledTypes.length === 0 && ( +

+ ⚠ No object types are currently enabled. Please enable at least one in Schema Configuration. +

+ )} +
+
+ + setInputs({ ...inputs, objectKey: e.target.value })} + className="w-full border border-gray-300 rounded-md px-3 py-2" + placeholder="APP-123" + /> +
+
+ + setInputs({ ...inputs, referencedObjectKey: e.target.value })} + className="w-full border border-gray-300 rounded-md px-3 py-2" + placeholder="HOST-123" + /> +
+
+ + setInputs({ ...inputs, disabledTypeName: e.target.value })} + className="w-full border border-gray-300 rounded-md px-3 py-2" + placeholder="HostingType" + /> +
+
+ + setInputs({ ...inputs, updateField: e.target.value })} + className="w-full border border-gray-300 rounded-md px-3 py-2" + placeholder="description" + /> +
+
+ + setInputs({ ...inputs, updateValue: e.target.value })} + className="w-full border border-gray-300 rounded-md px-3 py-2" + placeholder="Updated value" + /> +
+
+
+ + {/* Utility Actions */} +
+

Utility Actions

+
+ + +
+
+ + {/* Test Buttons */} +
+ + + + + +
+ + {/* Test Results */} +
+ {Object.entries(testResults).map(([testId, result]) => ( +
+
+

{result.test}

+ + {result.status} + +
+ + {result.message && ( +

{result.message}

+ )} + + {result.data && ( +
+

Data:

+
+                  {JSON.stringify(result.data, null, 2)}
+                
+
+ )} + + {result.sqlQueries && result.sqlQueries.length > 0 && ( +
+

SQL Queries & Results:

+ {result.sqlQueries.map((query, idx) => ( +
+
+ {query.sql} +
+
+                      {JSON.stringify(query.result, null, 2)}
+                    
+
+ ))} +
+ )} + + {result.timestamp && ( +

Ran at: {new Date(result.timestamp).toLocaleString()}

+ )} +
+ ))} +
+ + {Object.keys(testResults).length === 0 && ( +
+ No tests run yet. Use the buttons above to start testing. +
+ )} +
+ ); +} diff --git a/frontend/src/components/BIASyncDashboard.tsx b/frontend/src/components/BIASyncDashboard.tsx index 593a073..5949dee 100644 --- a/frontend/src/components/BIASyncDashboard.tsx +++ b/frontend/src/components/BIASyncDashboard.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import { clsx } from 'clsx'; import { getBIAComparison, updateApplication, getBusinessImpactAnalyses } from '../services/api'; import type { BIAComparisonItem, BIAComparisonResponse, ReferenceValue } from '../types'; +import PageHeader from './PageHeader'; type MatchStatusFilter = 'all' | 'match' | 'mismatch' | 'not_found' | 'no_excel_bia'; type MatchTypeFilter = 'all' | 'exact' | 'search_reference' | 'fuzzy' | 'none'; @@ -261,16 +262,33 @@ export default function BIASyncDashboard() { if (loading) { return ( -
-
+
+
+

BIA Sync data laden...

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

Fout bij laden

+

{error}

+ +
+
); } @@ -325,78 +343,18 @@ export default function BIASyncDashboard() { return (
{/* Page header */} -
-

BIA Sync Dashboard

-

- Vergelijk Business Impact Analyse waarden uit Excel met de CMDB -

-
- - {/* Summary cards */} -
-
-
Totaal
-
{data.summary.total}
-
-
-
Match
-
{data.summary.matched}
-
-
-
Verschil
-
{data.summary.mismatched}
-
-
-
Niet in CMDB
-
{data.summary.notFound}
-
-
-
Niet in Excel
-
{data.summary.noExcelBIA}
-
-
- - {/* Filters */} -
-
-
- setSearchText(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" - /> -
-
- -
-
- -
+ + + + } + actions={ + } + /> + + {/* Summary cards */} +
+
+
+
Totaal
+
+ + + +
+
+
{data.summary.total}
+
+
+
+
Match
+
+ + + +
+
+
{data.summary.matched}
+
+
+
+
Verschil
+
+ + + +
+
+
{data.summary.mismatched}
+
+
+
+
Niet in CMDB
+
+ + + +
+
+
{data.summary.notFound}
+
+
+
+
Niet in Excel
+
+ + + +
+
+
{data.summary.noExcelBIA}
+
+
+ + {/* Filters */} +
+
+
+ +
+
+ + + +
+ setSearchText(e.target.value)} + className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" + /> +
+
+
+ + +
+
+ + +
{/* Table */} -
+
{/* Placeholder spacer when header is fixed */} {isHeaderFixed && headerHeight > 0 && (
)} - + - - - - - - @@ -455,150 +523,154 @@ export default function BIASyncDashboard() { ) : ( filteredApplications.map((item) => ( - - + - - - - - + + + + - + + + + + {item.allMatches && item.allMatches.length > 1 && ( + + + + )} + )) )} @@ -607,8 +679,18 @@ export default function BIASyncDashboard() { {/* Results count */} -
- {filteredApplications.length} van {data.applications.length} applicaties getoond +
+
+ {filteredApplications.length} + van + {data.applications.length} + applicaties getoond +
+ {filteredApplications.length !== data.applications.length && ( +
+ Gefilterd +
+ )}
); diff --git a/frontend/src/components/BusinessImportanceComparison.tsx b/frontend/src/components/BusinessImportanceComparison.tsx index 551285f..dcc3dfb 100644 --- a/frontend/src/components/BusinessImportanceComparison.tsx +++ b/frontend/src/components/BusinessImportanceComparison.tsx @@ -3,6 +3,7 @@ import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Re import { getBusinessImportanceComparison } from '../services/api'; import type { BusinessImportanceComparisonItem } from '../types'; import { Link } from 'react-router-dom'; +import PageHeader from './PageHeader'; interface ScatterDataPoint { x: number; // Business Importance (0-6) @@ -138,12 +139,15 @@ export default function BusinessImportanceComparison() { return (
{/* Header */} -
-

Business Importance vs Business Impact Analysis

-

- Vergelijking tussen IT-infrastructuur prioritering (Business Importance) en business owner beoordeling (Business Impact Analysis) -

-
+ + + + } + /> {/* Summary Statistics Cards */}
diff --git a/frontend/src/components/CacheStatusIndicator.tsx b/frontend/src/components/CacheStatusIndicator.tsx index cf7923c..1a1533f 100644 --- a/frontend/src/components/CacheStatusIndicator.tsx +++ b/frontend/src/components/CacheStatusIndicator.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; -import { getCacheStatus, triggerSync, type CacheStatus } from '../services/api'; +import { getCacheStatus, triggerSync, type CacheStatus, ApiError } from '../services/api'; +import { toastManager } from './Toast'; interface CacheStatusIndicatorProps { /** Show compact version (just icon + time) */ @@ -15,15 +16,15 @@ export const CacheStatusIndicator: React.FC = ({ const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); - const [error, setError] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); const fetchStatus = async () => { try { const data = await getCacheStatus(); setStatus(data); - setError(null); } catch (err) { - setError('Kon status niet ophalen'); + const errorMessage = err instanceof Error ? err.message : 'Kon status niet ophalen'; + toastManager.error(errorMessage, 6000); } finally { setLoading(false); } @@ -40,54 +41,67 @@ export const CacheStatusIndicator: React.FC = ({ setSyncing(true); try { await triggerSync(); + toastManager.success('Synchronisatie gestart. Dit kan even duren...'); // Refetch status after a short delay setTimeout(fetchStatus, 1000); } catch (err) { - setError('Sync mislukt'); + console.error('Sync failed:', err); + let errorMessage = 'Sync mislukt'; + if (err instanceof ApiError) { + errorMessage = err.message || 'Sync mislukt'; + // Check if it's a configuration error + if (err.status === 400 && err.data && typeof err.data === 'object' && 'message' in err.data) { + errorMessage = String(err.data.message); + } + } else if (err instanceof Error) { + errorMessage = err.message; + } else if (typeof err === 'object' && err !== null && 'error' in err) { + errorMessage = String(err.error); + } else if (typeof err === 'string') { + errorMessage = err; + } + // Show error in toast notification only + toastManager.error(errorMessage, 8000); } 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'; - + // Handle compact mode early returns if (compact) { + if (loading) { + return ( +
+ + + + + Laden... +
+ ); + } + + if (!status) { + return ( +
+ 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; + const statusColor = !isWarm + ? 'text-amber-600' + : ageMinutes !== null && ageMinutes > 5 + ? 'text-amber-600' + : 'text-emerald-600'; + return ( +
+
+ +

Cache Status

+
+ {!isExpanded && ( +
+ {loading ? ( + Laden... + ) : status ? ( + <> + + {totalObjects.toLocaleString()} objecten + + + {isWarm ? 'Actief' : 'Cold Start'} + + + ) : ( + Geen data + )} +
+ )} +
+
+ + {isExpanded && ( +
+
+ {loading ? ( +
+ + + + + Status laden... +
+ ) : !status ? ( +
+ Geen status beschikbaar +
+ ) : ( + <> +
+ Objecten in cache: + {status.cache.totalObjects.toLocaleString()} +
+
+ Relaties: + {status.cache.totalRelations.toLocaleString()} +
+
+ Laatst gesynchroniseerd: + + {ageMinutes !== null ? `${ageMinutes} min geleden` : 'Nooit'} + +
+ + )} + {isSyncing && ( +
+ + + + + Synchronisatie bezig... +
+ )} +
+ + +
+ )}
); }; diff --git a/frontend/src/components/ComplexityDynamicsBubbleChart.tsx b/frontend/src/components/ComplexityDynamicsBubbleChart.tsx index 5e9131e..fd05201 100644 --- a/frontend/src/components/ComplexityDynamicsBubbleChart.tsx +++ b/frontend/src/components/ComplexityDynamicsBubbleChart.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { ScatterChart, Scatter, XAxis, YAxis, ZAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'; import { searchApplications } from '../services/api'; import type { ReferenceValue, ApplicationStatus } from '../types'; +import PageHeader from './PageHeader'; import { Link } from 'react-router-dom'; const ALL_STATUSES: ApplicationStatus[] = [ @@ -170,12 +171,15 @@ export default function ComplexityDynamicsBubbleChart() { return (
{/* Header */} -
-

Complexity vs Dynamics Bubble Chart

-

- X-as: Complexity, Y-as: Dynamics, Grootte: FTE, Kleur: BIA -

-
+ + + + } + /> {/* Filters */}
diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 06cbf9b..278626c 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import { getDashboardStats, getRecentClassifications, getReferenceData } from '../services/api'; import type { DashboardStats, ClassificationResult, ReferenceValue } from '../types'; import GovernanceModelBadge from './GovernanceModelBadge'; +import PageHeader from './PageHeader'; // Extended type to include stale indicator from API interface DashboardStatsWithMeta extends DashboardStats { @@ -74,45 +75,64 @@ export default function Dashboard() { return (
{/* Page header */} -
-
-

Dashboard

-

+ Overzicht van de ZiRA classificatie voortgang {stats?.stale && ( - + (gecachte data - API timeout) )} -

-
-
- - - Start classificeren - -
-
+ + + + {refreshing ? 'Laden...' : 'Ververs'} + + + Start classificeren + + + } + progress={ + stats + ? { + current: stats.classifiedCount || 0, + total: stats.totalApplications || 0, + label: 'Classificatie voortgang', + } + : undefined + } + /> {/* Stats cards */}
diff --git a/frontend/src/components/DataCompletenessConfig.tsx b/frontend/src/components/DataCompletenessConfig.tsx index 0a99d6c..4e57359 100644 --- a/frontend/src/components/DataCompletenessConfig.tsx +++ b/frontend/src/components/DataCompletenessConfig.tsx @@ -8,6 +8,7 @@ import { type CompletenessCategoryConfig, type SchemaResponse, } from '../services/api'; +import PageHeader from './PageHeader'; // Mapping from schema fieldName to ApplicationDetails field path // Some fields have different names in ApplicationDetails vs schema @@ -456,13 +457,15 @@ export default function DataCompletenessConfig() { return (
{/* Header */} -
-

Data Completeness Configuration

-

- Create and configure categories and fields for the Data Completeness Score calculation. - Fields are dynamically loaded from the "Application Component" object type in the datamodel. -

-
+ + + + } + /> {/* Success/Error Messages */} {success && ( diff --git a/frontend/src/components/DataCompletenessScore.tsx b/frontend/src/components/DataCompletenessScore.tsx index fe5e3b5..63529f8 100644 --- a/frontend/src/components/DataCompletenessScore.tsx +++ b/frontend/src/components/DataCompletenessScore.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import type { DataCompletenessConfig } from '../services/api'; +import PageHeader from './PageHeader'; interface FieldCompleteness { field: string; @@ -145,12 +146,15 @@ export default function DataCompletenessScore() { return (
{/* Header */} -
-

Data Completeness Score

-

- Percentage van verplichte velden ingevuld per applicatie, per team en overall. -

-
+ + + + } + /> {/* Overall Score Cards */}
diff --git a/frontend/src/components/DataModelDashboard.tsx b/frontend/src/components/DataModelDashboard.tsx index 7d628a4..e11e63a 100644 --- a/frontend/src/components/DataModelDashboard.tsx +++ b/frontend/src/components/DataModelDashboard.tsx @@ -38,6 +38,9 @@ function AttributeRow({ attr, onReferenceClick }: { attr: SchemaAttributeDefinit
+
+ Applicatie + BIA (CMDB) + BIA (Excel) + Status + Match Type + Actie
-
-
- e.stopPropagation()} - > - {item.name} - -
-
- {item.key} - {item.searchReference && ` • ${item.searchReference}`} -
- {item.excelApplicationName && item.excelApplicationName !== item.name && ( -
- Excel: {item.excelApplicationName} -
- )} - {item.allMatches && item.allMatches.length > 1 && ( -
-
+
+
+ e.stopPropagation()} > - ⚠️ {item.allMatches.length} matches gevonden - - - - - {expandedMatches.has(item.id) && ( -
-
Alle gevonden matches:
-
    - {item.allMatches.map((match, idx) => ( -
  • - {idx + 1}. "{match.excelApplicationName}" → BIA: {match.biaValue} ({match.matchType}, {(match.confidence * 100).toFixed(0)}%) - {match.excelApplicationName === item.excelApplicationName && ' ← Huidige selectie'} -
  • - ))} -
-
- )} + {item.name} +
- )} -
-
-
- {item.currentBIA ? ( - {item.currentBIA.name} - ) : ( - - - )} -
-
-
- {item.excelBIA ? ( - {item.excelBIA} - ) : ( - - - )} -
-
- - {getStatusLabel(item.matchStatus)} - - -
- {getMatchTypeLabel(item.matchType)} - {item.matchConfidence !== undefined && ( - - ({(item.matchConfidence * 100).toFixed(0)}%) - - )} -
-
- {item.excelBIA && item.matchStatus !== 'match' && ( - +
+ {item.currentBIA ? ( + {item.currentBIA.name} + ) : ( + - + )} +
+
+
+ {item.excelBIA ? ( + {item.excelBIA} + ) : ( + - + )} +
+
+ - {savingIds.has(item.id) ? ( - - - - - - Opslaan... + {getStatusLabel(item.matchStatus)} + + +
+ {getMatchTypeLabel(item.matchType)} + {item.matchConfidence !== undefined && ( + + ({(item.matchConfidence * 100).toFixed(0)}%) - ) : ( - 'Opslaan' )} - - )} -
+ {item.excelBIA && item.matchStatus !== 'match' && ( + + )} +
+ + {expandedMatches.has(item.id) && ( +
+
Alle gevonden matches:
+
    + {item.allMatches.map((match, idx) => ( +
  • + {idx + 1}. "{match.excelApplicationName}" → BIA: {match.biaValue} ({match.matchType}, {(match.confidence * 100).toFixed(0)}%) + {match.excelApplicationName === item.excelApplicationName && ' ← Huidige selectie'} +
  • + ))} +
+
+ )} +
{attr.fieldName} + {attr.position !== undefined && attr.position !== null ? attr.position : '—'} + {attr.isMultiple && ( @@ -103,6 +106,8 @@ function ObjectTypeCard({ cacheCount?: number; jiraCount?: number; }) { + // Attributes are already sorted by position from the database (ORDER BY position, jira_attr_id) + // We only need to separate reference and non-reference attributes const referenceAttrs = objectType.attributes.filter(a => a.type === 'reference'); const nonReferenceAttrs = objectType.attributes.filter(a => a.type !== 'reference'); @@ -125,7 +130,19 @@ function ObjectTypeCard({
-

{objectType.name}

+
+

{objectType.name}

+ {/* Sync Status Badge */} + {objectType.enabled ? ( + + ✓ Enabled + + ) : ( + + ○ Disabled + + )} +

{objectType.typeName}

@@ -133,6 +150,11 @@ function ObjectTypeCard({
{displayCount.toLocaleString()} objects + {objectType.enabled && cacheCount !== undefined && ( + + ({cacheCount.toLocaleString()} cached) + + )}
{objectType.attributes.length} attributes @@ -150,20 +172,21 @@ function ObjectTypeCard({ )}
- {/* Refresh button */} - + + )} Field + + Position + Type @@ -504,7 +531,14 @@ export default function DataModelDashboard() {
{schema.metadata.objectTypeCount}
-
Object Types
+
+ Object Types + {schema.metadata.enabledObjectTypeCount !== undefined && ( + + ({schema.metadata.enabledObjectTypeCount} enabled) + + )} +
diff --git a/frontend/src/components/DataValidationDashboard.tsx b/frontend/src/components/DataValidationDashboard.tsx new file mode 100644 index 0000000..09e4a7a --- /dev/null +++ b/frontend/src/components/DataValidationDashboard.tsx @@ -0,0 +1,789 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + getDataValidationStats, + getDataValidationObjects, + getBrokenReferences, + repairBrokenReferences, + type DataValidationStats, + type DataValidationObjectsResponse, + type BrokenReference, +} from '../services/api'; +import PageHeader from './PageHeader'; +import ObjectDetailModal from './ObjectDetailModal'; + +type Tab = 'overview' | 'types' | 'broken-refs'; + +export default function DataValidationDashboard() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState('overview'); + const [selectedType, setSelectedType] = useState(null); + const [objects, setObjects] = useState(null); + const [loadingObjects, setLoadingObjects] = useState(false); + const [brokenRefs, setBrokenRefs] = useState([]); + const [loadingBrokenRefs, setLoadingBrokenRefs] = useState(false); + const [brokenRefsPage, setBrokenRefsPage] = useState(0); + const [brokenRefsTotal, setBrokenRefsTotal] = useState(0); + const [selectedObjectId, setSelectedObjectId] = useState(null); + const [objectHistory, setObjectHistory] = useState([]); + const [repairing, setRepairing] = useState(false); + const [repairResult, setRepairResult] = useState<{ repaired: number; deleted: number; failed: number } | null>(null); + const [expandedSchemas, setExpandedSchemas] = useState>(new Set()); + + const loadStats = useCallback(async () => { + try { + setLoading(true); + setError(null); + const data = await getDataValidationStats(); + setStats(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load validation stats'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadStats(); + }, [loadStats]); + + const loadObjects = useCallback(async (typeName: string, page: number = 0) => { + try { + setLoadingObjects(true); + const limit = 20; + const offset = page * limit; + const data = await getDataValidationObjects(typeName, limit, offset); + setObjects(data); + } catch (err) { + console.error('Failed to load objects:', err); + } finally { + setLoadingObjects(false); + } + }, []); + + const loadBrokenReferences = useCallback(async (page: number = 0) => { + try { + setLoadingBrokenRefs(true); + const limit = 50; + const offset = page * limit; + const data = await getBrokenReferences(limit, offset); + setBrokenRefs(data.brokenReferences); + setBrokenRefsTotal(data.pagination.total); + } catch (err) { + console.error('Failed to load broken references:', err); + } finally { + setLoadingBrokenRefs(false); + } + }, []); + + useEffect(() => { + if (activeTab === 'broken-refs') { + loadBrokenReferences(brokenRefsPage); + } + }, [activeTab, brokenRefsPage, loadBrokenReferences]); + + const handleTypeClick = (typeName: string) => { + setSelectedType(typeName); + setActiveTab('types'); + loadObjects(typeName, 0); + }; + + const handleRepair = async () => { + if (repairing) return; + + setRepairing(true); + setRepairResult(null); + + try { + const result = await repairBrokenReferences({ + mode: 'fetch', // Try to fetch missing objects first, then delete if not found + batchSize: 100, + maxRepairs: 0, // Unlimited + }); + + setRepairResult({ + repaired: result.result.repaired, + deleted: result.result.deleted, + failed: result.result.failed, + }); + + // Reload stats and broken references + await loadStats(); + await loadBrokenReferences(brokenRefsPage); + } catch (err) { + console.error('Failed to repair broken references:', err); + setRepairResult({ + repaired: 0, + deleted: 0, + failed: 1, + }); + } finally { + setRepairing(false); + } + }; + + + const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; + }; + + const formatDate = (dateString: string | null): string => { + if (!dateString) return 'Nooit'; + const date = new Date(dateString); + return date.toLocaleString('nl-NL', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + // Group typeComparisons by schema + const groupedBySchema = (() => { + if (!stats) return new Map(); + + const grouped = new Map(); + const noSchemaGroup: typeof stats.comparison.typeComparisons = []; + + for (const comp of stats.comparison.typeComparisons) { + const schemaKey = comp.schemaId && comp.schemaName + ? `${comp.schemaId}|${comp.schemaName}` + : '__NO_SCHEMA__'; + + if (schemaKey === '__NO_SCHEMA__') { + noSchemaGroup.push(comp); + } else { + if (!grouped.has(schemaKey)) { + grouped.set(schemaKey, []); + } + grouped.get(schemaKey)!.push(comp); + } + } + + if (noSchemaGroup.length > 0) { + grouped.set('__NO_SCHEMA__|Geen Schema', noSchemaGroup); + } + + return grouped; + })(); + + const toggleSchemaExpanded = (schemaKey: string) => { + const newExpanded = new Set(expandedSchemas); + if (newExpanded.has(schemaKey)) { + newExpanded.delete(schemaKey); + } else { + newExpanded.add(schemaKey); + } + setExpandedSchemas(newExpanded); + }; + + if (loading) { + return ( +
+
+
+

Laden van validatie statistieken...

+
+
+ ); + } + + if (error || !stats) { + return ( +
+ + + +

Fout bij laden

+

{error || 'Geen data beschikbaar'}

+ +
+ ); + } + + return ( +
+ {/* Header */} + + + + } + /> + + {/* Stats Cards */} +
+
+
+
+ + + +
+
+
+ {typeof stats.cache.totalObjects === 'number' + ? stats.cache.totalObjects.toLocaleString('nl-NL') + : Number(stats.cache.totalObjects || 0).toLocaleString('nl-NL')} +
+
Objecten in cache
+
+ +
+
+
+ + + +
+
+
+ {typeof stats.cache.totalRelations === 'number' + ? stats.cache.totalRelations.toLocaleString('nl-NL') + : Number(stats.cache.totalRelations || 0).toLocaleString('nl-NL')} +
+
Relaties
+
+ +
+
+
+ + + +
+
+
+ {typeof stats.validation.brokenReferences === 'number' + ? stats.validation.brokenReferences.toLocaleString('nl-NL') + : Number(stats.validation.brokenReferences || 0).toLocaleString('nl-NL')} +
+
Kapotte referenties
+
+ +
+
+
+ + + +
+
+
{formatBytes(stats.cache.dbSizeBytes)}
+
Database grootte
+
+
+ + {/* Sync Status */} +
+
+
+ + + +
+

Sync Status

+
+
+
+
Laatste volledige sync
+
{formatDate(stats.cache.lastFullSync)}
+
+
+
Laatste incrementele sync
+
{formatDate(stats.cache.lastIncrementalSync)}
+
+
+
+ + {/* Tabs */} +
+
+ +
+ +
+ {/* Overview Tab */} + {activeTab === 'overview' && ( +
+

Sync Status per Type

+
+
+
{stats.comparison.totalSynced}
+
Gesynchroniseerd
+
+
+
{stats.comparison.totalOutdated}
+
Verouderd
+
+
+
{stats.comparison.totalMissing}
+
Niet gesynchroniseerd
+
+
+ + {/* Grouped by Schema */} +
+ {Array.from(groupedBySchema.entries()).map(([schemaKey, typeComps]) => { + const [schemaId, schemaName] = schemaKey.split('|'); + const isExpanded = expandedSchemas.has(schemaKey); + const syncedCount = typeComps.filter(t => t.syncStatus === 'synced').length; + const outdatedCount = typeComps.filter(t => t.syncStatus === 'outdated').length; + const missingCount = typeComps.filter(t => t.syncStatus === 'missing').length; + + return ( +
+ {/* Schema Header */} +
toggleSchemaExpanded(schemaKey)} + > +
+
+ +
+
+ + + +
+
+

{schemaName}

+

ID: {schemaId === '__NO_SCHEMA__' ? 'N/A' : schemaId} • {typeComps.length} types

+
+
+
+
+
+
+ {syncedCount} + / + {outdatedCount} + / + {missingCount} +
+
gesynchroniseerd / verouderd / ontbreekt
+
+
+
+
+ + {/* Object Types List */} + {isExpanded && ( +
+
+ + + + + + + + + + + + {typeComps.map((comp) => ( + handleTypeClick(comp.typeName)} + > + + + + + + + ))} + +
+ Type + + Cache + + Jira + + Verschil + + Status +
+
{comp.typeDisplayName}
+
{comp.typeName}
+
+ {comp.cacheCount.toLocaleString()} + + {comp.jiraCount.toLocaleString()} + + {comp.difference !== 0 && ( + 0 ? 'text-amber-600' : 'text-emerald-600'}`}> + {comp.difference > 0 ? '+' : ''}{comp.difference.toLocaleString()} + + )} + {comp.difference === 0 && } + + {comp.syncStatus === 'synced' && ( + + ✓ Gesynchroniseerd + + )} + {comp.syncStatus === 'outdated' && ( + + ⚠ Verouderd + + )} + {comp.syncStatus === 'missing' && ( + + ✗ Niet gesynchroniseerd + + )} +
+
+
+ )} +
+ ); + })} +
+
+ )} + + {/* Types Tab */} + {activeTab === 'types' && ( +
+ {!selectedType ? ( +
+ + + +

Selecteer een type uit de overzicht tab om objecten te bekijken

+
+ ) : ( +
+
+

+ {objects?.typeDisplayName || selectedType} +

+

{selectedType}

+
+ {loadingObjects ? ( +
+
+

Laden...

+
+ ) : objects ? ( +
+
+ {objects.pagination.total.toLocaleString()} objecten totaal +
+
+ + + + + + + + + + {objects.objects.map((obj: any) => ( + setSelectedObjectId(obj.id)} + > + + + + + ))} + +
+ ID + + Key + + Label +
+ {obj.id} + + {obj.objectKey} + {obj.label}
+
+ {objects.pagination.hasMore && ( +
+ +
+ )} +
+ ) : ( +
+ + + +

Geen objecten gevonden

+
+ )} +
+ )} +
+ )} + + {/* Broken References Tab */} + {activeTab === 'broken-refs' && ( +
+
+
+
+

Kapotte Referenties

+

+ Referenties naar objecten die niet in de cache aanwezig zijn ({brokenRefsTotal.toLocaleString()} totaal) +

+
+ {brokenRefsTotal > 0 && ( + + )} +
+ {repairResult && ( +
+
+ {repairResult.repaired > 0 && ( +
+ + + + {repairResult.repaired} gerepareerd +
+ )} + {repairResult.deleted > 0 && ( +
+ + + + {repairResult.deleted} verwijderd +
+ )} + {repairResult.failed > 0 && ( +
+ + + + {repairResult.failed} mislukt +
+ )} +
+
+ )} +
+ {loadingBrokenRefs ? ( +
+
+

Laden...

+
+ ) : brokenRefs.length > 0 ? ( +
+
+ + + + + + + + + + + {brokenRefs.map((ref, idx) => ( + setSelectedObjectId(ref.object_id)} + > + + + + + + ))} + +
+ Object + + Type + + Attribuut + + Referentie ID +
+
{ref.label}
+
{ref.object_key}
+
+ {ref.object_type_name} + + {ref.field_name} + + + {ref.reference_object_id} + +
+
+ {brokenRefsTotal > (brokenRefsPage + 1) * 50 && ( +
+ +
+ )} +
+ ) : ( +
+ + + +

Geen kapotte referenties gevonden

+

Alle referenties zijn geldig!

+
+ )} +
+ )} + +
+
+ + {/* Object Detail Modal */} + { + setSelectedObjectId(null); + setObjectHistory([]); + }} + onObjectClick={(objectId) => { + // Add current object to history before navigating + if (selectedObjectId && selectedObjectId !== objectId) { + setObjectHistory(prev => { + // Only add if it's not already the last item + if (prev.length === 0 || prev[prev.length - 1] !== selectedObjectId) { + return [...prev, selectedObjectId]; + } + return prev; + }); + } + setSelectedObjectId(objectId); + }} + onBack={() => { + if (objectHistory.length > 0) { + const previousObjectId = objectHistory[objectHistory.length - 1]; + setObjectHistory(prev => prev.slice(0, -1)); + setSelectedObjectId(previousObjectId); + } + }} + canGoBack={objectHistory.length > 0} + /> +
+ ); +} diff --git a/frontend/src/components/EffortDisplay.tsx b/frontend/src/components/EffortDisplay.tsx index ffc6800..b186137 100644 --- a/frontend/src/components/EffortDisplay.tsx +++ b/frontend/src/components/EffortDisplay.tsx @@ -55,8 +55,13 @@ export function EffortDisplay({ const dynamicsFactor = breakdown?.dynamicsFactor ?? { value: 1.0, name: null }; const complexityFactor = breakdown?.complexityFactor ?? { value: 1.0, name: null }; + // Ensure factor values are numbers (convert if needed) + const numberOfUsersValue = typeof numberOfUsersFactor?.value === 'number' ? numberOfUsersFactor.value : parseFloat(String(numberOfUsersFactor?.value ?? 1.0)) || 1.0; + const dynamicsValue = typeof dynamicsFactor?.value === 'number' ? dynamicsFactor.value : parseFloat(String(dynamicsFactor?.value ?? 1.0)) || 1.0; + const complexityValue = typeof complexityFactor?.value === 'number' ? complexityFactor.value : parseFloat(String(complexityFactor?.value ?? 1.0)) || 1.0; + // Calculate final min/max FTE by applying factors to base min/max - const factorMultiplier = numberOfUsersFactor.value * dynamicsFactor.value * complexityFactor.value; + const factorMultiplier = numberOfUsersValue * dynamicsValue * complexityValue; const finalMinFTE = baseEffortMin !== null ? baseEffortMin * factorMultiplier : null; const finalMaxFTE = baseEffortMax !== null ? baseEffortMax * factorMultiplier : null; @@ -224,16 +229,16 @@ export function EffortDisplay({ {/* Factors */}
Factoren:
- Number of Users: × {numberOfUsersFactor.value.toFixed(2)} - {numberOfUsersFactor.name && ` (${numberOfUsersFactor.name})`} + Number of Users: × {numberOfUsersValue.toFixed(2)} + {numberOfUsersFactor?.name && ` (${numberOfUsersFactor.name})`}
- Dynamics Factor: × {dynamicsFactor.value.toFixed(2)} - {dynamicsFactor.name && ` (${dynamicsFactor.name})`} + Dynamics Factor: × {dynamicsValue.toFixed(2)} + {dynamicsFactor?.name && ` (${dynamicsFactor.name})`}
- Complexity Factor: × {complexityFactor.value.toFixed(2)} - {complexityFactor.name && ` (${complexityFactor.name})`} + Complexity Factor: × {complexityValue.toFixed(2)} + {complexityFactor?.name && ` (${complexityFactor.name})`}
{/* Hours breakdown */} diff --git a/frontend/src/components/FTECalculator.tsx b/frontend/src/components/FTECalculator.tsx index 41b83f9..1145c67 100644 --- a/frontend/src/components/FTECalculator.tsx +++ b/frontend/src/components/FTECalculator.tsx @@ -138,199 +138,370 @@ export default function FTECalculator() { }); }, [numberOfUsers]); + // Calculate completion percentage + const completionCount = [ + selectedGovernanceModel, + selectedApplicationType, + selectedBusinessImpactAnalyse, + selectedHosting, + selectedNumberOfUsers, + selectedDynamicsFactor, + selectedComplexityFactor, + ].filter(Boolean).length; + const totalFields = 7; + const completionPercentage = Math.round((completionCount / totalFields) * 100); + if (loading) { return ( -
-
+
+
+

Laden van referentiedata...

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

Fout bij laden

+

{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} - /> +
+ {/* Header with gradient */} +
+
+
+
+ + +
- - {/* 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} - /> +

FTE Calculator

+

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

- - {/* 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... + {/* Progress indicator */} +
+
+ Voortgang + {completionCount} van {totalFields} velden ingevuld ({completionPercentage}%)
- ) : ( -
- +
- )} +
+
- {!selectedGovernanceModel && ( -

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

- )} +
+ {/* Form Section - 2 columns */} +
+ {/* Application Classification Card */} +
+
+
+
+ + + +

Applicatie Classificatie

+
+ +
+
+ +
+ {/* 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} + className="shadow-sm" + /> +
+ + {/* Hosting */} +
+ + { + const selected = applicationManagementHosting.find((h) => h.objectId === value); + setSelectedHosting(selected || null); + }} + options={applicationManagementHosting} + placeholder="Selecteer Hosting..." + className="shadow-sm" + /> +
+
+ + {/* 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} + className="shadow-sm" + /> +
+ + {/* 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} + className="shadow-sm" + /> +
+ + {/* Dynamics Factor */} +
+ + { + const selected = dynamicsFactors.find((d) => d.objectId === value); + setSelectedDynamicsFactor(selected || null); + }} + options={dynamicsFactors} + placeholder="Selecteer Dynamics Factor..." + showSummary={true} + className="shadow-sm" + /> +
+ + {/* Complexity Factor */} +
+ + { + const selected = complexityFactors.find((c) => c.objectId === value); + setSelectedComplexityFactor(selected || null); + }} + options={complexityFactors} + placeholder="Selecteer Complexity Factor..." + showSummary={true} + className="shadow-sm" + /> +
+
+
+ + {/* ICT Governance Model - Required field with emphasis */} +
+ + { + const selected = governanceModels.find((m) => m.objectId === value); + setSelectedGovernanceModel(selected || null); + }} + options={governanceModels} + placeholder="Selecteer ICT Governance Model..." + showSummary={true} + className="shadow-sm" + /> + {!selectedGovernanceModel && ( +

+ + + + Dit veld is verplicht om een berekening uit te voeren +

+ )} +
+
+
+ + {/* Result Section - 1 column, sticky */} +
+
+
+
+ + + +

+ Berekening Resultaat +

+
+
+ +
+ {isCalculating ? ( +
+
+ Berekenen... +
+ ) : calculatedFte !== null && calculatedFte !== undefined ? ( +
+
+ +
+
+
+ + + +

+ Berekening voltooid op basis van de ingevulde velden +

+
+
+
+ ) : ( +
+
+ + + +
+

Nog geen berekening

+

+ Vul de classificatievelden in om een FTE berekening uit te voeren +

+ {!selectedGovernanceModel && ( +
+

+ + + + Selecteer minimaal het ICT Governance Model +

+
+ )} +
+ )} +
+
+
); diff --git a/frontend/src/components/FTEPerZiRADomain.tsx b/frontend/src/components/FTEPerZiRADomain.tsx index bb29cfd..4b28a5c 100644 --- a/frontend/src/components/FTEPerZiRADomain.tsx +++ b/frontend/src/components/FTEPerZiRADomain.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import PageHeader from './PageHeader'; import { Link } from 'react-router-dom'; interface FunctionFTE { @@ -132,12 +133,15 @@ export default function FTEPerZiRADomain() { return (
{/* Header */} -
-

FTE per ZiRA Domain

-

- Welke business domeinen vereisen het meeste IT management effort? -

-
+ + + + } + /> {/* Overall Statistics */}
diff --git a/frontend/src/components/GovernanceAnalysis.tsx b/frontend/src/components/GovernanceAnalysis.tsx index 530b5a9..97d2ba2 100644 --- a/frontend/src/components/GovernanceAnalysis.tsx +++ b/frontend/src/components/GovernanceAnalysis.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; +import PageHeader from './PageHeader'; interface ApplicationWithIssues { id: string; @@ -122,25 +123,32 @@ export default function GovernanceAnalysis() { return (
{/* Header */} -
-
-

Analyse Regiemodel

-

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

-
- - - + Standaard worden Closed en Deprecated applicaties uitgesloten. + + } + icon={ + + - Terug naar rapporten - -
+ } + actions={ + + + + + Terug naar rapporten + + } + /> {/* Summary Cards */}
diff --git a/frontend/src/components/GovernanceModelHelper.tsx b/frontend/src/components/GovernanceModelHelper.tsx index 1748256..8c02fbc 100644 --- a/frontend/src/components/GovernanceModelHelper.tsx +++ b/frontend/src/components/GovernanceModelHelper.tsx @@ -25,6 +25,7 @@ import { StatusBadge, BusinessImportanceBadge } from './ApplicationList'; import CustomSelect from './CustomSelect'; import { EffortDisplay } from './EffortDisplay'; import { useEffortCalculation, getEffectiveFte } from '../hooks/useEffortCalculation'; +import PageHeader from './PageHeader'; import type { ApplicationDetails, AISuggestion, @@ -259,7 +260,17 @@ export default function GovernanceModelHelper() { setOverrideFTE(app.overrideFTE ?? 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'); + console.error('Failed to load application:', err); + let errorMessage = 'Failed to load application'; + if (err instanceof Error) { + errorMessage = err.message; + } else if (typeof err === 'object' && err !== null && 'error' in err) { + errorMessage = String(err.error); + if ('details' in err) { + errorMessage += `: ${err.details}`; + } + } + setError(errorMessage); } finally { setLoading(false); } @@ -927,55 +938,93 @@ export default function GovernanceModelHelper() { /> )} - {/* Navigation header */} -
-
- - + {application.key && ( + + {application.key} + + )} + {application.status && ( + + )} + {applicationIds.length > 0 && ( + + • Applicatie {currentIndex + 1} van {applicationIds.length} + + )} +
+ } + icon={ + + + + } + actions={ +
+ - - - Terug - - | - - + + + Terug + + - - - Overzicht - -
- {applicationIds.length > 0 && ( - - Applicatie {currentIndex + 1} van {applicationIds.length} - - )} -
+ + + + Overzicht + + {jiraHost && application.key && ( + + + + + Jira + + )} +
+ } + /> {/* Application Info (Read-only) */}
diff --git a/frontend/src/components/LifecyclePipeline.tsx b/frontend/src/components/LifecyclePipeline.tsx index b3fef4f..2f3ce6a 100644 --- a/frontend/src/components/LifecyclePipeline.tsx +++ b/frontend/src/components/LifecyclePipeline.tsx @@ -102,19 +102,15 @@ export default function LifecyclePipeline() { return (
{/* Header */} -
-
-
- - - -
-

Lifecycle Pipeline

-
-

- Overzicht van applicaties verdeeld over de verschillende lifecycle fases en statussen -

-
+ + + + } + /> {/* Total Summary Card */}
diff --git a/frontend/src/components/ObjectDetailModal.tsx b/frontend/src/components/ObjectDetailModal.tsx new file mode 100644 index 0000000..e739d56 --- /dev/null +++ b/frontend/src/components/ObjectDetailModal.tsx @@ -0,0 +1,342 @@ +import { useState, useEffect } from 'react'; +import { getDataValidationObject, type DataValidationObjectResponse } from '../services/api'; +import { useAuthStore } from '../stores/authStore'; + +interface ObjectDetailModalProps { + objectId: string | null; + onClose: () => void; + onObjectClick?: (objectId: string) => void; + onBack?: () => void; + canGoBack?: boolean; +} + +interface ObjectReference { + objectId: string; + objectKey: string; + label: string; +} + +function isObjectReference(value: any): value is ObjectReference { + return value && typeof value === 'object' && 'objectId' in value; +} + +function formatValue(value: any): string { + if (value === null || value === undefined) return '—'; + if (typeof value === 'boolean') return value ? 'Ja' : 'Nee'; + if (typeof value === 'object') { + if (Array.isArray(value)) { + if (value.length === 0) return '—'; + return value.map(v => formatValue(v)).join(', '); + } + if (isObjectReference(value)) { + return value.label || value.objectKey || value.objectId; + } + return JSON.stringify(value); + } + return String(value); +} + +export default function ObjectDetailModal({ objectId, onClose, onObjectClick, onBack, canGoBack = false }: ObjectDetailModalProps) { + const [objectData, setObjectData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { config } = useAuthStore(); + + useEffect(() => { + if (!objectId) { + setObjectData(null); + return; + } + + const loadObject = async () => { + setLoading(true); + setError(null); + try { + const data = await getDataValidationObject(objectId); + setObjectData(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load object'); + } finally { + setLoading(false); + } + }; + + loadObject(); + }, [objectId]); + + if (!objectId) return null; + + const handleReferenceClick = (ref: ObjectReference, e: React.MouseEvent) => { + e.stopPropagation(); + if (onObjectClick) { + onObjectClick(ref.objectId); + } + }; + + const handleBack = () => { + if (onBack) { + onBack(); + } + }; + + const renderAttributeValue = (key: string, value: any) => { + // Skip internal/system fields + if (key.startsWith('_')) return null; + + // Handle reference fields + if (isObjectReference(value)) { + return ( + + ); + } + + // Handle arrays of references + if (Array.isArray(value) && value.length > 0 && isObjectReference(value[0])) { + return ( +
+ {value.map((ref, idx) => ( + + ))} +
+ ); + } + + // Regular values + return {formatValue(value)}; + }; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ {/* Back Button */} + {canGoBack && onBack && ( + + )} +
+ {loading ? ( +
+
+

Laden...

+
+ ) : objectData ? ( +
+
+
+ + + +
+

{objectData.metadata.label}

+
+
+ {objectData.metadata.objectKey} + + {objectData.metadata.typeDisplayName} +
+
+ ) : ( +

Object Details

+ )} +
+
+
+ {/* Jira Assets Link */} + {objectData && config?.jiraHost && ( + + + + + + )} + +
+
+ + {/* Content */} +
+ {error ? ( +
+ + + +

{error}

+
+ ) : objectData ? ( +
+ {/* Basic Info */} +
+

+
+ Basis Informatie +

+
+
+
+
ID
+
{objectData.metadata.objectKey}
+
+
+
Type
+
{objectData.metadata.typeDisplayName}
+
+
+
+
+ + {/* Attributes */} +
+

+
+ Attributen +

+
+ + + + + + + + + {Object.entries(objectData.object as Record) + .filter(([key]) => !key.startsWith('_')) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => { + const renderedValue = renderAttributeValue(key, value); + if (renderedValue === null) return null; + + return ( + + + + + ); + })} + {Object.entries(objectData.object as Record) + .filter(([key]) => !key.startsWith('_')).length === 0 && ( + + + + )} + +
+ Attribuut + + Waarde +
+ {key} + + {renderedValue} +
+ + + + Geen attributen beschikbaar +
+
+
+ + {/* Metadata */} + {objectData.object && typeof objectData.object === 'object' && '_jiraUpdatedAt' in objectData.object && ( +
+

+
+ Metadata +

+
+ {objectData.object._jiraUpdatedAt && ( +
+
+
Laatst bijgewerkt (Jira)
+
+ {new Date(objectData.object._jiraUpdatedAt).toLocaleString('nl-NL')} +
+
+ {objectData.object._jiraCreatedAt && ( +
+
Aangemaakt (Jira)
+
+ {new Date(objectData.object._jiraCreatedAt).toLocaleString('nl-NL')} +
+
+ )} +
+ )} +
+
+ )} +
+ ) : ( +
+
+

Laden van object details...

+
+ )} +
+ + {/* Footer */} +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/ObjectTypeConfigEditor.tsx b/frontend/src/components/ObjectTypeConfigEditor.tsx new file mode 100644 index 0000000..22bf9a8 --- /dev/null +++ b/frontend/src/components/ObjectTypeConfigEditor.tsx @@ -0,0 +1,261 @@ +import { useState, useEffect } from 'react'; +import { getObjectTypes, setObjectTypeEnabled, type ObjectTypeConfig, fetchApi } from '../services/api'; + +export default function ObjectTypeConfigEditor() { + const [objectTypes, setObjectTypes] = useState([]); + const [loading, setLoading] = useState(true); // Start with loading = true + const [updating, setUpdating] = useState(null); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [discovering, setDiscovering] = useState(false); + + useEffect(() => { + loadObjectTypes(); + }, []); + + const loadObjectTypes = async () => { + try { + setLoading(true); + setError(null); + console.log('Loading object types...'); + const data = await getObjectTypes(); + const types = data.objectTypes || []; + console.log('Object types loaded:', types.length, 'types'); + if (types.length === 0) { + console.warn('No object types found in database. Schema discovery may be needed.'); + } + setObjectTypes(types); + } catch (err) { + console.error('Failed to load object types:', err); + const errorMessage = err instanceof Error ? err.message : 'Failed to load object types'; + setError(errorMessage); + // Also log the full error for debugging + if (err && typeof err === 'object' && 'details' in err) { + console.error('Error details:', (err as any).details); + } + } finally { + setLoading(false); + } + }; + + const handleToggleEnabled = async (objectTypeName: string, currentEnabled: boolean) => { + try { + setUpdating(objectTypeName); + setError(null); + await setObjectTypeEnabled(objectTypeName, !currentEnabled); + await loadObjectTypes(); // Reload to get updated state + } catch (err) { + console.error('Failed to update object type:', err); + setError(err instanceof Error ? err.message : 'Failed to update object type'); + } finally { + setUpdating(null); + } + }; + + const handleDiscoverSchema = async () => { + try { + setDiscovering(true); + setError(null); + await fetchApi<{ success: boolean; message: string }>('/schema/discover', { + method: 'POST', + }); + // Wait a bit for discovery to complete, then reload + setTimeout(() => { + loadObjectTypes(); + }, 2000); + } catch (err) { + console.error('Failed to discover schema:', err); + setError(err instanceof Error ? err.message : 'Failed to discover schema'); + } finally { + setDiscovering(false); + } + }; + + const filteredTypes = objectTypes.filter(type => + type.displayName.toLowerCase().includes(searchTerm.toLowerCase()) || + type.typeName.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const enabledCount = objectTypes.filter(t => t.enabled).length; + const disabledCount = objectTypes.length - enabledCount; + + return ( +
+ {/* Header */} +
+
+
+

Object Type Configuratie

+

+ Configureer welke object types worden gesynchroniseerd. Alle attributen worden altijd gesynchroniseerd. +

+
+ +
+
+
+ Totaal:{' '} + {objectTypes.length} +
+
+ Ingeschakeld:{' '} + {enabledCount} +
+
+ Uitgeschakeld:{' '} + {disabledCount} +
+
+
+ + {error && ( +
+
+ + + +
+

Fout bij laden

+

{error}

+
+
+
+ )} + + {/* Search */} +
+ setSearchTerm(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+ + {/* Object Types List */} +
+
+

+ Object Types ({filteredTypes.length}) +

+
+ {loading ? ( +
+
+

Object types laden...

+

Dit kan even duren bij de eerste keer

+
+ ) : objectTypes.length === 0 ? ( +
+ + + +

Geen object types gevonden

+

+ {searchTerm ? ( + 'Probeer een andere zoekterm' + ) : ( + <> + De database bevat nog geen object types. Voer eerst een schema discovery uit om object types te ontdekken. +
+ + + )} +

+
+ ) : ( +
+ + + + + + + + + + + + {filteredTypes.map((type) => ( + + + + + + + + ))} + +
+ Object Type + + Schema ID + + Objecten + + Status + + Actie +
+
{type.displayName}
+
{type.typeName}
+ {type.description && ( +
{type.description}
+ )} +
+ {type.schemaId || '-'} + + {type.objectCount.toLocaleString('nl-NL')} + + {type.enabled ? ( + + ✓ Ingeschakeld + + ) : ( + + Uitgeschakeld + + )} + + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx new file mode 100644 index 0000000..60d7166 --- /dev/null +++ b/frontend/src/components/PageHeader.tsx @@ -0,0 +1,77 @@ +import React from 'react'; + +interface PageHeaderProps { + title: string; + description?: string; + icon?: React.ReactNode; + actions?: React.ReactNode; + badge?: React.ReactNode; + progress?: { + current: number; + total: number; + label?: string; + }; +} + +export default function PageHeader({ + title, + description, + icon, + actions, + badge, + progress, +}: PageHeaderProps) { + const progressPercentage = progress + ? Math.round((progress.current / progress.total) * 100) + : null; + + return ( +
+
+
+ {icon && ( +
+ {icon} +
+ )} +
+
+

{title}

+ {badge &&
{badge}
} +
+ {description && ( +

+ {description} +

+ )} +
+
+ {actions && ( +
+ {actions} +
+ )} +
+ + {/* Progress indicator */} + {progress && ( +
+
+ + {progress.label || 'Voortgang'} + + + {progress.current} van {progress.total} ({progressPercentage}%) + +
+
+
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/ReportsDashboard.tsx b/frontend/src/components/ReportsDashboard.tsx index afd0921..611c191 100644 --- a/frontend/src/components/ReportsDashboard.tsx +++ b/frontend/src/components/ReportsDashboard.tsx @@ -1,4 +1,5 @@ import { Link } from 'react-router-dom'; +import PageHeader from './PageHeader'; export default function ReportsDashboard() { const reports = [ @@ -220,12 +221,15 @@ export default function ReportsDashboard() { return (
{/* Header */} -
-

Rapporten

-

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

-
+ + + + } + /> {/* Reports Grid */}
diff --git a/frontend/src/components/RoleManagement.tsx b/frontend/src/components/RoleManagement.tsx index 6aa8409..4a60329 100644 --- a/frontend/src/components/RoleManagement.tsx +++ b/frontend/src/components/RoleManagement.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { useHasPermission } from '../hooks/usePermissions'; import ProtectedRoute from './ProtectedRoute'; +import PageHeader from './PageHeader'; const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; @@ -164,18 +165,26 @@ export default function RoleManagement() { return (
-
-
-

Rollenbeheer

-

Beheer rollen en rechten

-
- -
+ + + + } + actions={ + + } + /> {error && (
diff --git a/frontend/src/components/SchemaConfigurationSettings.tsx b/frontend/src/components/SchemaConfigurationSettings.tsx new file mode 100644 index 0000000..50008e6 --- /dev/null +++ b/frontend/src/components/SchemaConfigurationSettings.tsx @@ -0,0 +1,1096 @@ +import { useState, useEffect } from 'react'; +import { + getSchemaConfigurationStats, + discoverSchemasAndObjectTypes, + getConfiguredObjectTypes, + setConfiguredObjectTypeEnabled, + bulkSetObjectTypesEnabled, + checkConfiguration, + getSchema, + triggerTypeSync, + getSchemas, + setSchemaSearchEnabled, + type SchemaWithObjectTypes, + type ConfiguredObjectType, + type SchemaConfigurationStats, + type SchemaResponse, + type SchemaObjectTypeDefinition, + type SchemaAttributeDefinition, + type SchemaSearchConfig, +} from '../services/api'; +import CacheStatusIndicator from './CacheStatusIndicator'; +import PageHeader from './PageHeader'; + +// Attribute type badge colors (from DataModelDashboard) +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} + + ); +} + +export default function SchemaConfigurationSettings() { + const [stats, setStats] = useState(null); + const [schemas, setSchemas] = useState([]); + const [schemaDetails, setSchemaDetails] = useState(null); + const [schemaSearchConfigs, setSchemaSearchConfigs] = useState([]); + const [loading, setLoading] = useState(true); + const [discovering, setDiscovering] = useState(false); + const [updating, setUpdating] = useState(null); + const [updatingSearch, setUpdatingSearch] = useState(null); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [expandedSchemas, setExpandedSchemas] = useState>(new Set()); + const [expandedObjectTypes, setExpandedObjectTypes] = useState>(new Set()); + const [searchTerm, setSearchTerm] = useState(''); + const [refreshingTypes, setRefreshingTypes] = useState>(new Set()); + const [refreshedCounts, setRefreshedCounts] = useState>({}); + const [refreshErrors, setRefreshErrors] = useState>({}); + const [isSetupFlowExpanded, setIsSetupFlowExpanded] = useState(false); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + try { + setLoading(true); + setError(null); + const [statsData, schemasData, schemaDetailsData, searchConfigsData] = await Promise.all([ + getSchemaConfigurationStats(), + getConfiguredObjectTypes(), + getSchema().catch(() => null), // Schema details are optional + getSchemas().catch(() => ({ schemas: [] })), // Schema search configs are optional + ]); + setStats(statsData); + setSchemas(schemasData.schemas); + setSchemaDetails(schemaDetailsData); + setSchemaSearchConfigs(searchConfigsData.schemas); + } catch (err) { + console.error('Failed to load schema configuration:', err); + setError(err instanceof Error ? err.message : 'Failed to load schema configuration'); + } finally { + setLoading(false); + } + }; + + const handleRefreshType = async (objectTypeName: string) => { + setRefreshingTypes((prev) => new Set(prev).add(objectTypeName)); + setRefreshErrors((prev) => { + const next = { ...prev }; + delete next[objectTypeName]; + return next; + }); + + try { + const result = await triggerTypeSync(objectTypeName); + + if (result.stats?.objectsProcessed !== undefined) { + setRefreshedCounts((prev) => ({ + ...prev, + [objectTypeName]: result.stats.objectsProcessed, + })); + } + + // Reload data to update counts + await loadData(); + setSuccess(`Object type ${objectTypeName} succesvol gesynchroniseerd: ${result.stats.objectsProcessed} objecten verwerkt`); + } catch (err) { + console.error(`Failed to refresh ${objectTypeName}:`, err); + 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, + [objectTypeName]: errorMessage, + })); + } finally { + setRefreshingTypes((prev) => { + const next = new Set(prev); + next.delete(objectTypeName); + return next; + }); + } + }; + + const toggleObjectTypeExpanded = (objectTypeName: string) => { + setExpandedObjectTypes((prev) => { + const next = new Set(prev); + if (next.has(objectTypeName)) { + next.delete(objectTypeName); + } else { + next.add(objectTypeName); + } + return next; + }); + }; + + const getObjectTypeDetails = (objectTypeName: string): SchemaObjectTypeDefinition | null => { + if (!schemaDetails) return null; + return schemaDetails.objectTypes[objectTypeName] || null; + }; + + const handleDiscover = async () => { + try { + setDiscovering(true); + setError(null); + setSuccess(null); + const result = await discoverSchemasAndObjectTypes(); + setSuccess(`Discovery voltooid: ${result.schemasDiscovered} schemas, ${result.objectTypesDiscovered} object types gevonden`); + await loadData(); + } catch (err) { + console.error('Failed to discover schemas:', err); + setError(err instanceof Error ? err.message : 'Failed to discover schemas'); + } finally { + setDiscovering(false); + } + }; + + const handleToggleEnabled = async (id: string, currentEnabled: boolean) => { + // Store scroll position + const scrollPosition = window.scrollY; + + // Optimistic update - update state immediately + setSchemas(prevSchemas => + prevSchemas.map(schema => ({ + ...schema, + objectTypes: schema.objectTypes.map(ot => + ot.id === id ? { ...ot, enabled: !currentEnabled } : ot + ) + })) + ); + + // Update stats optimistically + if (stats) { + setStats(prevStats => { + if (!prevStats) return prevStats; + const newEnabled = !currentEnabled + ? prevStats.enabledObjectTypes + 1 + : prevStats.enabledObjectTypes - 1; + const newDisabled = !currentEnabled + ? prevStats.disabledObjectTypes - 1 + : prevStats.disabledObjectTypes + 1; + return { + ...prevStats, + enabledObjectTypes: newEnabled, + disabledObjectTypes: newDisabled, + isConfigured: newEnabled > 0, + }; + }); + } + + try { + setUpdating(id); + setError(null); + await setConfiguredObjectTypeEnabled(id, !currentEnabled); + // Refresh stats from server to ensure consistency + const newStats = await getSchemaConfigurationStats(); + setStats(newStats); + // Restore scroll position + window.scrollTo(0, scrollPosition); + } catch (err) { + console.error('Failed to update object type:', err); + setError(err instanceof Error ? err.message : 'Failed to update object type'); + // Revert optimistic update on error + await loadData(); + } finally { + setUpdating(null); + } + }; + + const handleToggleSchema = (schemaId: string) => { + const schema = schemas.find(s => s.schemaId === schemaId); + if (!schema) return; + + const allEnabled = schema.objectTypes.every(ot => ot.enabled); + const updates = schema.objectTypes.map(ot => ({ + id: ot.id, + enabled: !allEnabled, + })); + + handleBulkUpdate(updates); + }; + + const handleBulkUpdate = async (updates: Array<{ id: string; enabled: boolean }>) => { + // Store scroll position + const scrollPosition = window.scrollY; + + // Optimistic update - update state immediately + const updateMap = new Map(updates.map(u => [u.id, u.enabled])); + setSchemas(prevSchemas => + prevSchemas.map(schema => ({ + ...schema, + objectTypes: schema.objectTypes.map(ot => + updateMap.has(ot.id) ? { ...ot, enabled: updateMap.get(ot.id)! } : ot + ) + })) + ); + + // Update stats optimistically + if (stats) { + setStats(prevStats => { + if (!prevStats) return prevStats; + const enabledCount = updates.filter(u => u.enabled).length; + const disabledCount = updates.filter(u => !u.enabled).length; + const currentEnabled = prevStats.enabledObjectTypes; + const currentDisabled = prevStats.disabledObjectTypes; + + // Calculate new counts based on what changed + let newEnabled = currentEnabled; + let newDisabled = currentDisabled; + + // For each update, adjust counts + for (const update of updates) { + const currentOt = schemas + .flatMap(s => s.objectTypes) + .find(ot => ot.id === update.id); + if (currentOt) { + if (currentOt.enabled && !update.enabled) { + newEnabled--; + newDisabled++; + } else if (!currentOt.enabled && update.enabled) { + newEnabled++; + newDisabled--; + } + } + } + + return { + ...prevStats, + enabledObjectTypes: newEnabled, + disabledObjectTypes: newDisabled, + isConfigured: newEnabled > 0, + }; + }); + } + + try { + setUpdating('bulk'); + setError(null); + await bulkSetObjectTypesEnabled(updates); + // Refresh stats from server to ensure consistency + const newStats = await getSchemaConfigurationStats(); + setStats(newStats); + // Restore scroll position + window.scrollTo(0, scrollPosition); + } catch (err) { + console.error('Failed to bulk update:', err); + setError(err instanceof Error ? err.message : 'Failed to bulk update'); + // Revert optimistic update on error + await loadData(); + } finally { + setUpdating(null); + } + }; + + const toggleSchemaExpanded = (schemaId: string) => { + const newExpanded = new Set(expandedSchemas); + if (newExpanded.has(schemaId)) { + newExpanded.delete(schemaId); + } else { + newExpanded.add(schemaId); + } + setExpandedSchemas(newExpanded); + }; + + const getSchemaSearchEnabled = (schemaId: string): boolean => { + const config = schemaSearchConfigs.find(s => s.schemaId === schemaId); + return config?.searchEnabled ?? true; // Default to true if not found + }; + + const handleToggleSearchEnabled = async (schemaId: string, currentEnabled: boolean) => { + try { + setUpdatingSearch(schemaId); + setError(null); + + // Optimistic update + setSchemaSearchConfigs(prev => + prev.map(s => + s.schemaId === schemaId + ? { ...s, searchEnabled: !currentEnabled } + : s + ) + ); + + await setSchemaSearchEnabled(schemaId, !currentEnabled); + setSuccess(`Schema search ${!currentEnabled ? 'ingeschakeld' : 'uitgeschakeld'}`); + } catch (err) { + console.error('Failed to update schema search enabled:', err); + setError(err instanceof Error ? err.message : 'Failed to update schema search enabled'); + // Revert optimistic update + await loadData(); + } finally { + setUpdatingSearch(null); + } + }; + + const filteredSchemas = schemas.map(schema => ({ + ...schema, + objectTypes: schema.objectTypes.filter(ot => + ot.displayName.toLowerCase().includes(searchTerm.toLowerCase()) || + ot.objectTypeName.toLowerCase().includes(searchTerm.toLowerCase()) || + schema.schemaName.toLowerCase().includes(searchTerm.toLowerCase()) + ), + })).filter(schema => schema.objectTypes.length > 0 || searchTerm === ''); + + const allEnabledCount = schemas.reduce((sum, s) => sum + s.objectTypes.filter(ot => ot.enabled).length, 0); + const allDisabledCount = schemas.reduce((sum, s) => sum + s.objectTypes.filter(ot => !ot.enabled).length, 0); + + return ( +
+ {/* Header Section */} + + + + } + actions={ +
+ + +
+ } + /> + + {/* Quick Stats Bar */} + {stats && ( +
+
+
+
+
+ + + +
+
+
{stats.totalSchemas}
+
Schemas
+
+
+
+
+ + + +
+
+
{stats.totalObjectTypes}
+
Object Types
+
+
+ {schemaDetails && ( + <> +
+
+ + + +
+
+
{schemaDetails.metadata.totalAttributes}
+
Attributen
+
+
+
+
+ + + +
+
+
+ {Object.values(schemaDetails.objectTypes).reduce((sum, t) => sum + t.outgoingLinks.length, 0)} +
+
Relaties
+
+
+ + )} +
+
+ + + +
+
+
{stats.enabledObjectTypes}
+
Ingeschakeld
+
+
+
+
+ + + +
+
+
{stats.disabledObjectTypes}
+
Uitgeschakeld
+
+
+
+
+ {stats.isConfigured ? ( + + + + ) : ( + + + + )} +
+
+
+ {stats.isConfigured ? 'Geconfigureerd' : 'Niet geconfigureerd'} +
+
+ {stats.isConfigured ? 'Klaar voor sync' : 'Configuratie vereist'} +
+
+
+
+
+
+ )} + + {/* Setup Flow Guide */} +
+
+
setIsSetupFlowExpanded(!isSetupFlowExpanded)} + > +
+
+ +

Setup Flow

+
+
+
+ {isSetupFlowExpanded && ( +
+
+
+ 1 +
+
Ontdek Schemas
+
Klik op "Ontdek Schemas & Object Types" hierboven
+
+
+
+ 2 +
+
Configureer Types
+
Schakel object types in die je wilt synchroniseren
+
+
+
+ 3 +
+
Run Sync
+
Klik op "Nu synchroniseren" hieronder
+
+
+
+ 4 +
+
View Data
+
Bekijk data in "Datamodel" of "Data Validatie"
+
+
+
+
+ )} +
+ + {/* Cache Status & Sync */} +
+ +
+
+ + {/* Alerts */} + {error && ( +
+
+ + + +
+

Fout

+

{error}

+
+
+
+ )} + + {success && ( +
+
+ + + +
+

Succes

+

{success}

+
+
+
+ )} + + {stats && !stats.isConfigured && ( +
+
+ + + +
+

Configuratie vereist

+

+ Er zijn nog geen object types ingeschakeld. Schakel ten minste één object type in voordat je een synchronisatie kunt starten. +

+
+
+
+ )} + + {/* Search */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + className="w-full pl-9 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm" + /> +
+
+ + {/* Schemas and Object Types */} + {loading ? ( +
+
+
+

Schema's laden...

+

Even geduld aub

+
+
+ ) : schemas.length === 0 ? ( +
+
+
+ + + +
+

Geen schemas gevonden

+

+ Start met het ontdekken van schemas en object types vanuit Jira Assets. Dit is de eerste stap in het configuratieproces. +

+ +
+
+ ) : ( +
+ {filteredSchemas.map((schema) => { + const enabledCount = schema.objectTypes.filter(ot => ot.enabled).length; + const isExpanded = expandedSchemas.has(schema.schemaId); + const allEnabled = schema.objectTypes.length > 0 && schema.objectTypes.every(ot => ot.enabled); + const someEnabled = schema.objectTypes.some(ot => ot.enabled); + + return ( +
+ {/* Schema Header */} +
toggleSchemaExpanded(schema.schemaId)} + > +
+
+ +
+
+ + + +
+
+

{schema.schemaName}

+

ID: {schema.schemaId} • {schema.objectTypes.length} types

+
+
+
+
+
+
+ {enabledCount} + / + {schema.objectTypes.length} +
+
ingeschakeld
+
+ {/* Search Enabled Toggle */} +
+ Zoeken: + + {updatingSearch === schema.schemaId && ( + + )} +
+ +
+
+
+ + {/* Object Types List */} + {isExpanded && ( +
+ {schema.objectTypes.map((objectType) => { + const isObjectTypeExpanded = expandedObjectTypes.has(objectType.objectTypeName); + const objectTypeDetails = getObjectTypeDetails(objectType.objectTypeName); + const isRefreshing = refreshingTypes.has(objectType.objectTypeName); + const refreshedCount = refreshedCounts[objectType.objectTypeName]; + const refreshError = refreshErrors[objectType.objectTypeName]; + const displayCount = refreshedCount ?? schemaDetails?.jiraCounts?.[objectType.objectTypeName] ?? schemaDetails?.cacheCounts?.[objectType.objectTypeName] ?? objectType.objectCount; + + return ( +
+
toggleObjectTypeExpanded(objectType.objectTypeName)} + > +
+
+
+
+ + + +
+
+
+
+
{objectType.displayName}
+ {objectType.enabled && ( + + Actief + + )} + {objectTypeDetails ? ( + <> + {objectTypeDetails.incomingLinks.length > 0 && ( + + ← {objectTypeDetails.incomingLinks.length} + + )} + {objectTypeDetails.outgoingLinks.length > 0 && ( + + → {objectTypeDetails.outgoingLinks.length} + + )} + + ) : schemaDetails && ( + + Details niet geladen + + )} +
+
{objectType.objectTypeName}
+ {objectType.description && ( +
{objectType.description}
+ )} + {objectTypeDetails ? ( +
+ {objectTypeDetails.attributes.length} attributen +
+ ) : schemaDetails ? ( +
+ Voer "Ontdek Schemas & Object Types" uit om details te laden +
+ ) : null} +
+
+
+ {displayCount.toLocaleString('nl-NL')} +
+
objecten
+
+
+
+
e.stopPropagation()}> + {objectType.enabled && ( + + )} + +
+
+
+ + {/* Expanded Details */} + {isObjectTypeExpanded && ( +
+ {!objectTypeDetails && schemaDetails && ( +
+
+ + + + Details niet beschikbaar voor dit object type. Voer "Ontdek Schemas & Object Types" uit om alle details te laden. +
+
+ )} + + {refreshError && ( +
+
+ + + + {refreshError} +
+
+ )} + + {/* Relationships Section */} + {objectTypeDetails && (objectTypeDetails.incomingLinks.length > 0 || objectTypeDetails.outgoingLinks.length > 0) && ( +
+

+ Relationships +

+
+ {/* Incoming Links */} + {objectTypeDetails.incomingLinks.length > 0 && ( +
+
+ + + + Referenced by ({objectTypeDetails.incomingLinks.length}) +
+
+ {objectTypeDetails.incomingLinks.map((link, idx) => ( +
+ {link.fromTypeName} + ({link.attributeName}) + {link.isMultiple && []} +
+ ))} +
+
+ )} + + {/* Outgoing Links */} + {objectTypeDetails.outgoingLinks.length > 0 && ( +
+
+ + + + References ({objectTypeDetails.outgoingLinks.length}) +
+
+ {objectTypeDetails.outgoingLinks.map((link, idx) => ( +
+ {link.toTypeName} + ({link.attributeName}) + {link.isMultiple && []} +
+ ))} +
+
+ )} +
+
+ )} + + {/* Attributes Table */} + {objectTypeDetails && objectTypeDetails.attributes.length > 0 && ( +
+

+ Attributes ({objectTypeDetails.attributes.length}) +

+
+ + + + + + + + + + + + + + {objectTypeDetails.attributes.map((attr) => ( + + + + + + + + + + ))} + +
NameFieldPositionTypeReferenceDescriptionFlags
+ {attr.name} + {attr.isRequired && *} + + {attr.fieldName} + + {attr.position !== undefined && attr.position !== null ? attr.position : '—'} + + + {attr.isMultiple && ( + [] + )} + + {attr.referenceTypeName ? ( + {attr.referenceTypeName} + ) : ( + + )} + + {attr.description || '—'} + +
+ {attr.isSystem && ( + + SYS + + )} + {!attr.isEditable && !attr.isSystem && ( + + RO + + )} +
+
+
+
+ )} +
+ )} +
+ ); + })} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/SearchDashboard.tsx b/frontend/src/components/SearchDashboard.tsx index 3a2d5b2..244f73d 100644 --- a/frontend/src/components/SearchDashboard.tsx +++ b/frontend/src/components/SearchDashboard.tsx @@ -324,17 +324,17 @@ export default function SearchDashboard() { return (
- {/* Header Section */} -
-
- + {/* Header Section - Subtle design without blue bar */} +
+
+
-

+

CMDB Zoeken

-

+

Zoek naar applicaties, servers, infrastructuur en andere items in de CMDB van Zuyderland

@@ -692,7 +692,7 @@ export default function SearchDashboard() {
diff --git a/frontend/src/components/TeamDashboard.tsx b/frontend/src/components/TeamDashboard.tsx index 46b26a4..b36f0e9 100644 --- a/frontend/src/components/TeamDashboard.tsx +++ b/frontend/src/components/TeamDashboard.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; import { Link } from 'react-router-dom'; import { getTeamDashboardData, getReferenceData } from '../services/api'; import type { TeamDashboardData, TeamDashboardTeam, TeamDashboardSubteam, ApplicationStatus, ReferenceValue } from '../types'; +import PageHeader from './PageHeader'; const ALL_STATUSES: ApplicationStatus[] = [ 'In Production', @@ -708,12 +709,15 @@ export default function TeamDashboard() { return (
-
-

Team-indeling

-

- Overzicht van applicaties gegroepeerd per Team en Subteam -

-
+ + + + } + /> {/* Compact Filter Bar */}
diff --git a/frontend/src/components/TechnicalDebtHeatmap.tsx b/frontend/src/components/TechnicalDebtHeatmap.tsx index 6a2375c..fdc534a 100644 --- a/frontend/src/components/TechnicalDebtHeatmap.tsx +++ b/frontend/src/components/TechnicalDebtHeatmap.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; +import PageHeader from './PageHeader'; interface TechnicalDebtApplication { id: string; @@ -182,13 +183,15 @@ export default function TechnicalDebtHeatmap() { return (
{/* Header */} -
-

Technical Debt Heatmap

-

- Visualisatie van applicaties met End of Life, End of Support of Deprecated status gecombineerd met BIA classificatie. - Hoge BIA + EOL = kritiek risico. -

-
+ + + + } + /> {/* Summary Cards */}
diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..452a20e --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,217 @@ +import { useState, useEffect } from 'react'; + +export type ToastType = 'success' | 'error' | 'warning' | 'info'; + +export interface Toast { + id: string; + message: string; + type: ToastType; + duration?: number; // Auto-dismiss after this many ms (default: 5000) +} + +interface ToastContainerProps { + toasts: Toast[]; + onDismiss: (id: string) => void; +} + +function ToastContainer({ toasts, onDismiss }: ToastContainerProps) { + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((toast) => ( + + ))} +
+ ); +} + +interface ToastItemProps { + toast: Toast; + onDismiss: (id: string) => void; +} + +function ToastItem({ toast, onDismiss }: ToastItemProps) { + const [isVisible, setIsVisible] = useState(true); + + useEffect(() => { + // Auto-dismiss after duration + const duration = toast.duration ?? 5000; + if (duration > 0) { + const timer = setTimeout(() => { + setIsVisible(false); + setTimeout(() => onDismiss(toast.id), 300); // Wait for animation + }, duration); + return () => clearTimeout(timer); + } + }, [toast.id, toast.duration, onDismiss]); + + const colors = { + success: { + bg: 'bg-emerald-50', + border: 'border-emerald-200', + text: 'text-emerald-800', + icon: 'text-emerald-600', + iconBg: 'bg-emerald-100', + }, + error: { + bg: 'bg-red-50', + border: 'border-red-200', + text: 'text-red-800', + icon: 'text-red-600', + iconBg: 'bg-red-100', + }, + warning: { + bg: 'bg-amber-50', + border: 'border-amber-200', + text: 'text-amber-800', + icon: 'text-amber-600', + iconBg: 'bg-amber-100', + }, + info: { + bg: 'bg-blue-50', + border: 'border-blue-200', + text: 'text-blue-800', + icon: 'text-blue-600', + iconBg: 'bg-blue-100', + }, + }; + + const color = colors[toast.type]; + + const icons = { + success: ( + + + + ), + error: ( + + + + ), + warning: ( + + + + ), + info: ( + + + + ), + }; + + if (!isVisible) return null; + + return ( +
+
+ {icons[toast.type]} +
+
+

{toast.message}

+
+ +
+ ); +} + +// Toast hook/store +class ToastManager { + private listeners: Set<(toasts: Toast[]) => void> = new Set(); + private toasts: Toast[] = []; + + subscribe(listener: (toasts: Toast[]) => void) { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private notify() { + this.listeners.forEach((listener) => listener([...this.toasts])); + } + + show(message: string, type: ToastType = 'info', duration?: number) { + const id = Math.random().toString(36).substring(7); + const toast: Toast = { id, message, type, duration }; + this.toasts.push(toast); + this.notify(); + + // Auto-dismiss if duration is set + if (duration !== undefined && duration > 0) { + setTimeout(() => { + this.dismiss(id); + }, duration); + } + + return id; + } + + dismiss(id: string) { + this.toasts = this.toasts.filter((t) => t.id !== id); + this.notify(); + } + + success(message: string, duration?: number) { + return this.show(message, 'success', duration ?? 5000); + } + + error(message: string, duration?: number) { + return this.show(message, 'error', duration ?? 7000); // Errors stay longer + } + + warning(message: string, duration?: number) { + return this.show(message, 'warning', duration ?? 6000); + } + + info(message: string, duration?: number) { + return this.show(message, 'info', duration ?? 5000); + } + + getToasts(): Toast[] { + return [...this.toasts]; + } +} + +export const toastManager = new ToastManager(); + +// Hook to use toasts +export function useToasts() { + const [toasts, setToasts] = useState(toastManager.getToasts()); + + useEffect(() => { + const unsubscribe = toastManager.subscribe(setToasts); + return unsubscribe; + }, []); + + return { + toasts, + show: (message: string, type?: ToastType, duration?: number) => toastManager.show(message, type, duration), + success: (message: string, duration?: number) => toastManager.success(message, duration), + error: (message: string, duration?: number) => toastManager.error(message, duration), + warning: (message: string, duration?: number) => toastManager.warning(message, duration), + info: (message: string, duration?: number) => toastManager.info(message, duration), + dismiss: (id: string) => toastManager.dismiss(id), + }; +} + +// Toast container component +export function ToastContainerComponent() { + const { toasts, dismiss } = useToasts(); + return ; +} diff --git a/frontend/src/components/UserManagement.tsx b/frontend/src/components/UserManagement.tsx index 2f48d38..af84b32 100644 --- a/frontend/src/components/UserManagement.tsx +++ b/frontend/src/components/UserManagement.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { useHasPermission } from '../hooks/usePermissions'; import ProtectedRoute from './ProtectedRoute'; +import PageHeader from './PageHeader'; const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; @@ -318,21 +319,26 @@ export default function UserManagement() { return (
{/* Header */} -
-
-

Gebruikersbeheer

-

Beheer gebruikers, rollen en rechten

-
- -
+ } + actions={ + + } + /> {/* Success/Error Messages */} {success && ( diff --git a/frontend/src/components/ZiRADomainCoverage.tsx b/frontend/src/components/ZiRADomainCoverage.tsx index 8e8e91e..1f5acf3 100644 --- a/frontend/src/components/ZiRADomainCoverage.tsx +++ b/frontend/src/components/ZiRADomainCoverage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; +import PageHeader from './PageHeader'; interface ReferenceValue { objectId: string; @@ -173,12 +174,15 @@ export default function ZiRADomainCoverage() { return (
{/* Header */} -
-

ZiRA Domain Coverage

-

- Analyse van welke ZiRA functies goed ondersteund worden versus gaps in IT coverage -

-
+ + + + } + /> {/* Overall Statistics */}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d106d19..2c31e06 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -58,7 +58,7 @@ export class ApiError extends Error { // Base Fetch // ============================================================================= -async function fetchApi( +export async function fetchApi( endpoint: string, options: RequestInit = {} ): Promise { @@ -134,6 +134,26 @@ export async function getRelatedObjects( return fetchApi(`/applications/${applicationId}/related/${objectType}${params}`); } +/** + * Refresh/sync an application from Jira + * Forces a re-sync of the application from Jira and updates the cache + */ +export async function refreshApplication(id: string): Promise<{ + status: string; + applicationId: string; + applicationKey: string; + message: string; +}> { + return fetchApi<{ + status: string; + applicationId: string; + applicationKey: string; + message: string; + }>(`/cache/refresh-application/${id}`, { + method: 'POST', + }); +} + export interface UpdateApplicationOptions { /** The _jiraUpdatedAt from when the application was loaded for editing */ originalUpdatedAt?: string; @@ -672,6 +692,7 @@ export interface SchemaAttributeDefinition { referenceTypeId?: number; referenceTypeName?: string; description?: string; + position?: number; } export interface SchemaObjectTypeDefinition { @@ -680,6 +701,7 @@ export interface SchemaObjectTypeDefinition { typeName: string; syncPriority: number; objectCount: number; + enabled: boolean; // Whether this object type is enabled for syncing attributes: SchemaAttributeDefinition[]; incomingLinks: Array<{ fromType: string; @@ -697,6 +719,7 @@ export interface SchemaObjectTypeDefinition { export interface SchemaResponse { metadata: { + enabledObjectTypeCount?: number; generatedAt: string; objectTypeCount: number; totalAttributes: number; @@ -763,3 +786,358 @@ export async function getBIAComparison(): Promise { export async function getBusinessImportanceComparison(): Promise { return fetchApi('/applications/business-importance-comparison'); } + +// ============================================================================= +// Data Validation +// ============================================================================= + +export interface DataValidationStats { + cache: { + totalObjects: number; + totalRelations: number; + objectsByType: Record; + isWarm: boolean; + dbSizeBytes: number; + lastFullSync: string | null; + lastIncrementalSync: string | null; + }; + jira: { + counts: Record; + }; + comparison: { + typeComparisons: Array<{ + typeName: string; + typeDisplayName: string; + schemaId?: string; + schemaName?: string; + cacheCount: number; + jiraCount: number; + difference: number; + syncStatus: 'synced' | 'outdated' | 'missing'; + }>; + totalOutdated: number; + totalMissing: number; + totalSynced: number; + }; + validation: { + brokenReferences: number; + }; + relations: { + total: number; + }; +} + +export interface DataValidationObjectsResponse { + typeName: string; + typeDisplayName: string; + objects: unknown[]; + pagination: { + limit: number; + offset: number; + total: number; + hasMore: boolean; + }; +} + +export interface DataValidationObjectResponse { + object: unknown; + metadata: { + typeName: string; + typeDisplayName: string; + objectKey: string; + label: string; + }; +} + +export interface BrokenReference { + object_id: string; + attribute_id: number; + reference_object_id: string; + field_name: string; + object_type_name: string; + object_key: string; + label: string; +} + +export interface BrokenReferencesResponse { + brokenReferences: BrokenReference[]; + pagination: { + limit: number; + offset: number; + total: number; + hasMore: boolean; + }; +} + +export async function getDataValidationStats(): Promise { + return fetchApi('/data-validation/stats'); +} + +export async function getDataValidationObjects( + typeName: string, + limit: number = 10, + offset: number = 0 +): Promise { + return fetchApi(`/data-validation/objects/${typeName}?limit=${limit}&offset=${offset}`); +} + +export async function getDataValidationObject(id: string): Promise { + return fetchApi(`/data-validation/object/${id}`); +} + +export async function getBrokenReferences( + limit: number = 50, + offset: number = 0 +): Promise { + return fetchApi(`/data-validation/broken-references?limit=${limit}&offset=${offset}`); +} + +export interface RepairBrokenReferencesOptions { + mode?: 'delete' | 'fetch' | 'dry-run'; + batchSize?: number; + maxRepairs?: number; +} + +export interface RepairBrokenReferencesResponse { + status: string; + mode: string; + result: { + total: number; + repaired: number; + deleted: number; + failed: number; + errors: Array<{ reference: BrokenReference; error: string }>; + }; +} + +export async function repairBrokenReferences( + options: RepairBrokenReferencesOptions = {} +): Promise { + const params = new URLSearchParams(); + if (options.mode) params.append('mode', options.mode); + if (options.batchSize) params.append('batchSize', String(options.batchSize)); + if (options.maxRepairs) params.append('maxRepairs', String(options.maxRepairs)); + + const queryString = params.toString(); + return fetchApi( + `/data-validation/repair-broken-references${queryString ? `?${queryString}` : ''}`, + { method: 'POST' } + ); +} + +export interface ValidationStatus { + brokenReferences: number; + objectsWithBrokenRefs: number; + lastValidated: string; +} + +export async function getValidationStatus(): Promise { + return fetchApi('/data-validation/validation-status'); +} + +export interface FullIntegrityCheckResponse { + status: string; + result: { + validation: ValidationStatus; + repair?: RepairBrokenReferencesResponse['result']; + orphanedValues: number; + orphanedRelations: number; + }; +} + +export async function runFullIntegrityCheck(repair: boolean = false): Promise { + return fetchApi( + `/data-validation/full-integrity-check?repair=${repair}`, + { method: 'POST' } + ); +} + +// ============================================================================= +// Schema Mappings +// ============================================================================= + +export interface SchemaMapping { + objectTypeName: string; + schemaId: string; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface SchemaMappingsResponse { + mappings: SchemaMapping[]; +} + +export async function getSchemaMappings(): Promise { + return fetchApi('/data-validation/schema-mappings'); +} + +export async function setSchemaMapping( + objectTypeName: string, + schemaId: string, + enabled: boolean = true +): Promise<{ status: string; message: string }> { + return fetchApi<{ status: string; message: string }>('/data-validation/schema-mappings', { + method: 'POST', + body: JSON.stringify({ objectTypeName, schemaId, enabled }), + }); +} + +export async function deleteSchemaMapping(objectTypeName: string): Promise<{ status: string; message: string }> { + return fetchApi<{ status: string; message: string }>(`/data-validation/schema-mappings/${objectTypeName}`, { + method: 'DELETE', + }); +} + +// ============================================================================= +// Object Type Configuration +// ============================================================================= + +export interface ObjectTypeConfig { + typeName: string; + displayName: string; + description: string | null; + schemaId: string | null; + enabled: boolean; + objectCount: number; + syncPriority: number; +} + +export interface ObjectTypesResponse { + objectTypes: ObjectTypeConfig[]; +} + +export async function getObjectTypes(): Promise { + return fetchApi('/data-validation/object-types'); +} + +export async function setObjectTypeEnabled( + objectTypeName: string, + enabled: boolean +): Promise<{ status: string; message: string }> { + return fetchApi<{ status: string; message: string }>( + `/data-validation/object-types/${objectTypeName}/enabled`, + { + method: 'PATCH', + body: JSON.stringify({ enabled }), + } + ); +} + +// ============================================================================= +// Schema Configuration +// ============================================================================= + +export interface SchemaConfigurationStats { + totalSchemas: number; + totalObjectTypes: number; + enabledObjectTypes: number; + disabledObjectTypes: number; + isConfigured: boolean; +} + +export interface ConfiguredObjectType { + id: string; + schemaId: string; + schemaName: string; + objectTypeId: number; + objectTypeName: string; + displayName: string; + description: string | null; + objectCount: number; + enabled: boolean; + discoveredAt: string; + updatedAt: string; +} + +export interface SchemaWithObjectTypes { + schemaId: string; + schemaName: string; + objectTypes: ConfiguredObjectType[]; +} + +export interface SchemaConfigurationResponse { + schemas: SchemaWithObjectTypes[]; +} + +export interface ConfigurationCheckResponse { + isConfigured: boolean; + stats: SchemaConfigurationStats; +} + +export interface DiscoverSchemasResponse { + success: boolean; + message: string; + schemasDiscovered: number; + objectTypesDiscovered: number; +} + +export async function getSchemaConfigurationStats(): Promise { + return fetchApi('/schema-configuration/stats'); +} + +export async function discoverSchemasAndObjectTypes(): Promise { + return fetchApi('/schema-configuration/discover', { + method: 'POST', + }); +} + +export interface SchemaSearchConfig { + schemaId: string; + schemaName: string; + searchEnabled: boolean; +} + +export interface SchemasResponse { + schemas: SchemaSearchConfig[]; +} + +export async function getConfiguredObjectTypes(): Promise { + return fetchApi('/schema-configuration/object-types'); +} + +export async function getSchemas(): Promise { + return fetchApi('/schema-configuration/schemas'); +} + +export async function setSchemaSearchEnabled( + schemaId: string, + searchEnabled: boolean +): Promise<{ status: string; message: string }> { + return fetchApi<{ status: string; message: string }>( + `/schema-configuration/schemas/${schemaId}/search-enabled`, + { + method: 'PATCH', + body: JSON.stringify({ searchEnabled }), + } + ); +} + +export async function setConfiguredObjectTypeEnabled( + id: string, + enabled: boolean +): Promise<{ status: string; message: string }> { + return fetchApi<{ status: string; message: string }>( + `/schema-configuration/object-types/${id}/enabled`, + { + method: 'PATCH', + body: JSON.stringify({ enabled }), + } + ); +} + +export async function bulkSetObjectTypesEnabled( + updates: Array<{ id: string; enabled: boolean }> +): Promise<{ status: string; message: string }> { + return fetchApi<{ status: string; message: string }>( + '/schema-configuration/object-types/bulk-enabled', + { + method: 'PATCH', + body: JSON.stringify({ updates }), + } + ); +} + +export async function checkConfiguration(): Promise { + return fetchApi('/schema-configuration/check'); +} diff --git a/package.json b/package.json index 08038d8..26949c0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "zira-classificatie-tool", + "name": "cmdb-insight", "version": "1.0.0", - "description": "ZiRA Classificatie Tool voor Zuyderland CMDB", + "description": "CMDB Insight voor Zuyderland CMDB", "private": true, "workspaces": [ "backend", diff --git a/scripts/backup-database.sh b/scripts/backup-database.sh index c6824ed..e0b8f51 100755 --- a/scripts/backup-database.sh +++ b/scripts/backup-database.sh @@ -4,7 +4,7 @@ BACKUP_DIR="${BACKUP_DIR:-./backups}" DATE=$(date +%Y%m%d_%H%M%S) -CONTAINER_NAME="${CONTAINER_NAME:-zuyderland-cmdb-gui-backend-1}" +CONTAINER_NAME="${CONTAINER_NAME:-cmdb-insight-backend-1}" # Create backup directory if it doesn't exist mkdir -p "$BACKUP_DIR" diff --git a/scripts/build-and-push-azure.sh b/scripts/build-and-push-azure.sh index 287ecdf..ac74871 100755 --- a/scripts/build-and-push-azure.sh +++ b/scripts/build-and-push-azure.sh @@ -4,7 +4,7 @@ set -e # Configuration - Azure Container Registry ACR_NAME="${ACR_NAME:-zuyderlandcmdbacr}" # Pas aan naar jouw ACR naam REGISTRY="${REGISTRY:-${ACR_NAME}.azurecr.io}" -REPO_NAME="${REPO_NAME:-zuyderland-cmdb-gui}" +REPO_NAME="${REPO_NAME:-cmdb-insight}" VERSION="${1:-latest}" # Colors for output diff --git a/scripts/reset-and-rebuild.sh b/scripts/reset-and-rebuild.sh new file mode 100755 index 0000000..ff3ea9b --- /dev/null +++ b/scripts/reset-and-rebuild.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +# Script to completely reset the database cache and rebuild from scratch +# This clears all cached data and triggers a full sync from Jira + +set -e + +echo "🔄 Complete Database Reset and Rebuild" +echo "======================================" +echo "" + +# Check if backend is running +BACKEND_URL="${BACKEND_URL:-http://localhost:3001}" +API_URL="${API_URL:-$BACKEND_URL/api}" + +echo "📡 Checking backend connection..." +if ! curl -s -f "$BACKEND_URL/health" > /dev/null 2>&1; then + echo "⚠️ Backend is not accessible at $BACKEND_URL" + echo " Make sure the backend is running and accessible" + echo "" + read -p "Continue anyway? (yes/no): " confirm + if [ "$confirm" != "yes" ]; then + echo "❌ Aborted." + exit 1 + fi +fi + +echo "✅ Backend is accessible" +echo "" + +# Check if we need authentication +echo "🔐 Checking authentication..." +AUTH_HEADER="" +if [ -n "$API_TOKEN" ]; then + AUTH_HEADER="-H \"Authorization: Bearer $API_TOKEN\"" + echo "✅ Using API token from environment" +elif [ -f ".env" ]; then + # Try to get token from .env or session + echo "ℹ️ Note: You may need to authenticate via the UI first" + echo " Or set API_TOKEN environment variable" +fi +echo "" + +# Step 1: Clear all cache +echo "🗑️ Step 1: Clearing all cached data..." +CLEAR_RESPONSE=$(curl -s -X DELETE "$API_URL/cache/clear" \ + -H "Content-Type: application/json" \ + $AUTH_HEADER 2>&1) + +if echo "$CLEAR_RESPONSE" | grep -q "cleared\|status"; then + echo "✅ Cache cleared successfully" +else + echo "⚠️ Clear response: $CLEAR_RESPONSE" + echo " This might indicate an authentication issue" + echo "" + read -p "Continue with sync anyway? (yes/no): " confirm + if [ "$confirm" != "yes" ]; then + echo "❌ Aborted." + exit 1 + fi +fi +echo "" + +# Step 2: Trigger full sync +echo "🔄 Step 2: Triggering full sync from Jira..." +SYNC_RESPONSE=$(curl -s -X POST "$API_URL/cache/sync" \ + -H "Content-Type: application/json" \ + $AUTH_HEADER 2>&1) + +if echo "$SYNC_RESPONSE" | grep -q "started\|status"; then + echo "✅ Full sync started in background" + echo "" + echo "📊 The sync is running in the background. You can monitor progress:" + echo " - Check cache status: curl $API_URL/cache/status" + echo " - View backend logs: docker-compose logs -f backend" + echo " - Or check the UI: $BACKEND_URL (Settings → Cache Management)" +else + echo "⚠️ Sync response: $SYNC_RESPONSE" + echo " This might indicate an authentication issue or missing Jira credentials" +fi +echo "" + +echo "✨ Reset and rebuild process initiated!" +echo "" +echo "Next steps:" +echo "1. Monitor sync progress via the UI or API" +echo "2. Wait for sync to complete (this may take several minutes)" +echo "3. Verify data in the UI or via: curl $API_URL/cache/status" +echo "" diff --git a/scripts/reset-postgres.sh b/scripts/reset-postgres.sh new file mode 100755 index 0000000..e40600b --- /dev/null +++ b/scripts/reset-postgres.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +# Script om PostgreSQL database volledig te resetten +# Dit simuleert een green field deployment +# Gebruikt docker-compose.dev.yml voor development setup + +set -e + +COMPOSE_FILE="docker-compose.dev.yml" + +echo "🔄 PostgreSQL Database Reset Script" +echo "====================================" +echo "" + +# Check if docker-compose file exists +if [ ! -f "$COMPOSE_FILE" ]; then + echo "⚠️ $COMPOSE_FILE not found, trying docker-compose.yml..." + COMPOSE_FILE="docker-compose.yml" +fi + +# Check if docker-compose is running +if docker-compose -f "$COMPOSE_FILE" ps 2>/dev/null | grep -q "postgres.*Up"; then + echo "📦 PostgreSQL container is running" + echo "" + + # Stop containers + echo "⏹️ Stopping containers..." + docker-compose -f "$COMPOSE_FILE" down + echo "✅ Containers stopped" + echo "" +else + echo "ℹ️ Containers are not running" + echo "" +fi + +# Remove PostgreSQL volume (this deletes all data!) +echo "🗑️ Removing PostgreSQL volume (this deletes ALL data)..." +read -p "Are you sure you want to delete all PostgreSQL data? (yes/no): " confirm + +if [ "$confirm" != "yes" ]; then + echo "❌ Aborted. No data was deleted." + exit 1 +fi + +# Try to find volume name +VOLUME_NAME=$(docker volume ls | grep postgres_data | awk '{print $2}' | head -1) +if [ -n "$VOLUME_NAME" ]; then + docker volume rm "$VOLUME_NAME" 2>/dev/null || echo "⚠️ Volume not found (might already be deleted)" + echo "✅ Volume removed" +else + echo "⚠️ No postgres_data volume found" +fi +echo "" + +# Start containers again +echo "🚀 Starting containers with fresh database..." +docker-compose -f "$COMPOSE_FILE" up -d postgres + +# Wait for PostgreSQL to be ready +echo "⏳ Waiting for PostgreSQL to be ready..." +timeout=30 +counter=0 +until docker-compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U cmdb > /dev/null 2>&1; do + sleep 1 + counter=$((counter + 1)) + if [ $counter -ge $timeout ]; then + echo "❌ Timeout waiting for PostgreSQL" + exit 1 + fi +done +echo "✅ PostgreSQL is ready" +echo "" + +# Create databases (if needed) +echo "📊 Creating databases..." +docker-compose -f "$COMPOSE_FILE" exec -T postgres psql -U cmdb -c "CREATE DATABASE cmdb_cache;" 2>/dev/null || echo "ℹ️ Database cmdb_cache already exists or will be created automatically" +docker-compose -f "$COMPOSE_FILE" exec -T postgres psql -U cmdb -c "CREATE DATABASE cmdb_classifications;" 2>/dev/null || echo "ℹ️ Database cmdb_classifications already exists or will be created automatically" +echo "✅ Databases ready" +echo "" + +echo "✨ PostgreSQL database has been reset!" +echo "" +echo "Next steps:" +echo "1. Start the backend locally: cd backend && npm run dev" +echo "2. The normalized schema will be created automatically on first start" +echo "3. Run schema discovery: npm run generate-schema (in backend/)" +echo "4. Start syncing data from Jira" +echo ""