Files
cmdb-insight/backend/src/services/cmdbService.ts
Bert Hausmans a7f8301196 Add database adapter system, production deployment configs, and new dashboard components
- Add PostgreSQL and SQLite database adapters with factory pattern
- Add migration script for SQLite to PostgreSQL
- Add production Dockerfiles and docker-compose configs
- Add deployment documentation and scripts
- Add BIA sync dashboard and matching service
- Add data completeness configuration and components
- Add new dashboard components (BusinessImportanceComparison, ComplexityDynamics, etc.)
- Update various services and routes
- Remove deprecated management-parameters.json and taxonomy files
2026-01-14 00:38:40 +01:00

450 lines
13 KiB
TypeScript

/**
* CMDBService - Universal schema-driven CMDB service
*
* Provides a unified interface for all CMDB operations:
* - Reads from cache for fast access
* - Write-through to Jira with conflict detection
* - Schema-driven parsing and updates
*/
import { logger } from './logger.js';
import { cacheStore, type CacheStats } from './cacheStore.js';
import { jiraAssetsClient, type JiraUpdatePayload, JiraObjectNotFoundError } from './jiraAssetsClient.js';
import { conflictResolver, type ConflictCheckResult } from './conflictResolver.js';
import { OBJECT_TYPES, getAttributeDefinition } from '../generated/jira-schema.js';
import type { CMDBObject, CMDBObjectTypeName, ObjectReference } from '../generated/jira-types.js';
// =============================================================================
// Types
// =============================================================================
export interface GetObjectOptions {
/** Force refresh from Jira (bypasses cache) */
forceRefresh?: boolean;
}
export interface UpdateResult {
success: boolean;
data?: CMDBObject;
conflict?: ConflictCheckResult;
error?: string;
}
export interface SearchOptions {
limit?: number;
offset?: number;
searchTerm?: string;
}
// =============================================================================
// Service Implementation
// =============================================================================
class CMDBService {
// ==========================================================================
// Read Operations
// ==========================================================================
/**
* Get a single object by ID
* By default reads from cache; use forceRefresh to fetch from Jira
*/
async getObject<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
id: string,
options?: GetObjectOptions
): Promise<T | null> {
// Force refresh: always fetch from Jira
if (options?.forceRefresh) {
return this.fetchAndCacheObject<T>(typeName, id);
}
// Try cache first
const cached = await cacheStore.getObject<T>(typeName, id);
if (cached) {
return cached;
}
// Cache miss: fetch from Jira
return this.fetchAndCacheObject<T>(typeName, id);
}
/**
* Get a single object by object key (e.g., "ICMT-123")
*/
async getObjectByKey<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
objectKey: string,
options?: GetObjectOptions
): Promise<T | null> {
// Force refresh: search Jira by key
if (options?.forceRefresh) {
const typeDef = OBJECT_TYPES[typeName];
if (!typeDef) return null;
const iql = `objectType = "${typeDef.name}" AND Key = "${objectKey}"`;
const result = await jiraAssetsClient.searchObjects(iql, 1, 1);
if (result.objects.length === 0) return null;
const parsed = jiraAssetsClient.parseObject<T>(result.objects[0]);
if (parsed) {
await cacheStore.upsertObject(typeName, parsed);
await cacheStore.extractAndStoreRelations(typeName, parsed);
}
return parsed;
}
// Try cache first
const cached = await cacheStore.getObjectByKey<T>(typeName, objectKey);
if (cached) {
return cached;
}
// Cache miss: search Jira
return this.getObjectByKey(typeName, objectKey, { forceRefresh: true });
}
/**
* Fetch a single object from Jira and update cache
* If the object was deleted from Jira (404), it will be removed from the local cache
*/
private async fetchAndCacheObject<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
id: string
): Promise<T | null> {
try {
const jiraObj = await jiraAssetsClient.getObject(id);
if (!jiraObj) return null;
const parsed = jiraAssetsClient.parseObject<T>(jiraObj);
if (parsed) {
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
if (error instanceof JiraObjectNotFoundError) {
const deleted = await cacheStore.deleteObject(typeName, id);
if (deleted) {
logger.info(`CMDBService: Removed deleted object ${typeName}/${id} from cache`);
}
return null;
}
// Re-throw other errors
throw error;
}
}
/**
* Get all objects of a type from cache
*/
async getObjects<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
options?: SearchOptions
): Promise<T[]> {
if (options?.searchTerm) {
return await cacheStore.searchByLabel<T>(typeName, options.searchTerm, {
limit: options.limit,
offset: options.offset,
});
}
return await cacheStore.getObjects<T>(typeName, {
limit: options?.limit,
offset: options?.offset,
});
}
/**
* Count objects of a type in cache
*/
async countObjects(typeName: CMDBObjectTypeName): Promise<number> {
return await cacheStore.countObjects(typeName);
}
/**
* Search across all object types
*/
async searchAllTypes(searchTerm: string, options?: { limit?: number }): Promise<CMDBObject[]> {
return await cacheStore.searchAllTypes(searchTerm, { limit: options?.limit });
}
/**
* Get related objects (outbound references)
*/
async getRelatedObjects<T extends CMDBObject>(
sourceId: string,
attributeName: string,
targetTypeName: CMDBObjectTypeName
): Promise<T[]> {
return await cacheStore.getRelatedObjects<T>(sourceId, targetTypeName, attributeName);
}
/**
* Get objects that reference the given object (inbound references)
*/
async getReferencingObjects<T extends CMDBObject>(
targetId: string,
sourceTypeName: CMDBObjectTypeName,
attributeName?: string
): Promise<T[]> {
return await cacheStore.getReferencingObjects<T>(targetId, sourceTypeName, attributeName);
}
// ==========================================================================
// Write Operations
// ==========================================================================
/**
* Update an object with conflict detection
*
* @param typeName - The object type
* @param id - The object ID
* @param updates - Field updates (only changed fields)
* @param originalUpdatedAt - The _jiraUpdatedAt from when the object was loaded for editing
*/
async updateObject(
typeName: CMDBObjectTypeName,
id: string,
updates: Record<string, unknown>,
originalUpdatedAt: string
): Promise<UpdateResult> {
try {
// 1. Check for conflicts
const conflictResult = await conflictResolver.checkConflict(
typeName,
id,
originalUpdatedAt,
updates
);
if (conflictResult.hasConflict && conflictResult.conflicts && conflictResult.conflicts.length > 0) {
return {
success: false,
conflict: conflictResult,
};
}
// 2. Build Jira update payload
const payload = this.buildUpdatePayload(typeName, updates);
if (payload.attributes.length === 0) {
logger.warn(`CMDBService: No attributes to update for ${typeName} ${id}`);
return { success: true };
}
// 3. Send update to Jira
const success = await jiraAssetsClient.updateObject(id, payload);
if (!success) {
return {
success: false,
error: 'Failed to update object in Jira',
};
}
// 4. Fetch fresh data and update cache
const freshData = await this.fetchAndCacheObject(typeName, id);
logger.info(`CMDBService: Updated ${typeName} ${id}`);
return {
success: true,
data: freshData || undefined,
};
} catch (error) {
logger.error(`CMDBService: Update failed for ${typeName} ${id}`, error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Force update without conflict check (use with caution)
*/
async forceUpdateObject(
typeName: CMDBObjectTypeName,
id: string,
updates: Record<string, unknown>
): Promise<UpdateResult> {
try {
const payload = this.buildUpdatePayload(typeName, updates);
if (payload.attributes.length === 0) {
return { success: true };
}
const success = await jiraAssetsClient.updateObject(id, payload);
if (!success) {
return {
success: false,
error: 'Failed to update object in Jira',
};
}
const freshData = await this.fetchAndCacheObject(typeName, id);
return {
success: true,
data: freshData || undefined,
};
} catch (error) {
logger.error(`CMDBService: Force update failed for ${typeName} ${id}`, error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Build Jira update payload from field updates
*/
private buildUpdatePayload(
typeName: CMDBObjectTypeName,
updates: Record<string, unknown>
): JiraUpdatePayload {
const attributes: JiraUpdatePayload['attributes'] = [];
logger.debug(`CMDBService.buildUpdatePayload: Building payload for ${typeName}`, {
updateKeys: Object.keys(updates),
updates: JSON.stringify(updates, null, 2)
});
for (const [fieldName, value] of Object.entries(updates)) {
const attrDef = getAttributeDefinition(typeName, fieldName);
if (!attrDef) {
logger.warn(`CMDBService: Unknown attribute ${fieldName} for ${typeName}`);
continue;
}
if (!attrDef.isEditable) {
logger.warn(`CMDBService: Attribute ${fieldName} is not editable`);
continue;
}
const attrValues = this.buildAttributeValues(value, attrDef);
logger.debug(`CMDBService.buildUpdatePayload: Attribute ${fieldName} (jiraId: ${attrDef.jiraId}, type: ${attrDef.type}, isMultiple: ${attrDef.isMultiple})`, {
inputValue: JSON.stringify(value),
outputValues: JSON.stringify(attrValues)
});
attributes.push({
objectTypeAttributeId: attrDef.jiraId,
objectAttributeValues: attrValues,
});
}
logger.debug(`CMDBService.buildUpdatePayload: Final payload`, {
attributeCount: attributes.length,
payload: JSON.stringify({ attributes }, null, 2)
});
return { attributes };
}
/**
* Build attribute values for Jira API
*/
private buildAttributeValues(
value: unknown,
attrDef: { type: string; isMultiple: boolean }
): Array<{ value?: string }> {
// Null/undefined = clear the field
if (value === null || value === undefined) {
return [];
}
// Reference type
if (attrDef.type === 'reference') {
if (attrDef.isMultiple && Array.isArray(value)) {
return (value as ObjectReference[]).map(ref => ({
value: ref.objectKey,
}));
} else if (!attrDef.isMultiple) {
const ref = value as ObjectReference;
return [{ value: ref.objectKey }];
}
return [];
}
// Boolean
if (attrDef.type === 'boolean') {
return [{ value: value ? 'true' : 'false' }];
}
// Number types
if (attrDef.type === 'integer' || attrDef.type === 'float') {
return [{ value: String(value) }];
}
// String types
return [{ value: String(value) }];
}
// ==========================================================================
// Cache Management
// ==========================================================================
/**
* Get cache statistics
*/
async getCacheStats(): Promise<CacheStats> {
return await cacheStore.getStats();
}
/**
* Check if cache has data
*/
async isCacheWarm(): Promise<boolean> {
return await cacheStore.isWarm();
}
/**
* Clear cache for a specific type
*/
async clearCacheForType(typeName: CMDBObjectTypeName): Promise<void> {
await cacheStore.clearObjectType(typeName);
}
/**
* Clear entire cache
*/
async clearCache(): Promise<void> {
await cacheStore.clearAll();
}
// ==========================================================================
// User Token Management (for OAuth)
// ==========================================================================
/**
* Set user token for current request
*/
setUserToken(token: string | null): void {
if (token) {
jiraAssetsClient.setRequestToken(token);
} else {
jiraAssetsClient.clearRequestToken();
}
}
/**
* Clear user token
*/
clearUserToken(): void {
jiraAssetsClient.clearRequestToken();
}
}
// Export singleton instance
export const cmdbService = new CMDBService();