Improve Team-indeling dashboard UI and cache invalidation
- Replace 'TEAM' label with Type attribute (Business/Enabling/Staf) in team blocks - Make Type labels larger (text-sm) and brighter colors - Make SUBTEAM label less bright (indigo-300) and smaller (text-[10px]) - Add 'FTE' suffix to bandbreedte values in header and application blocks - Add Platform and Connected Device labels to application blocks - Show Platform FTE and Workloads FTE separately in Platform blocks - Add spacing between Regiemodel letter and count value - Add cache invalidation for Team Dashboard when applications are updated - Enrich team references with Type attribute in getSubteamToTeamMapping
This commit is contained in:
445
backend/src/services/cmdbService.ts
Normal file
445
backend/src/services/cmdbService.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* CMDBService - Universal schema-driven CMDB service
|
||||
*
|
||||
* Provides a unified interface for all CMDB operations:
|
||||
* - Reads from cache for fast access
|
||||
* - Write-through to Jira with conflict detection
|
||||
* - Schema-driven parsing and updates
|
||||
*/
|
||||
|
||||
import { logger } from './logger.js';
|
||||
import { cacheStore, type CacheStats } from './cacheStore.js';
|
||||
import { jiraAssetsClient, type JiraUpdatePayload, JiraObjectNotFoundError } from './jiraAssetsClient.js';
|
||||
import { conflictResolver, type ConflictCheckResult } from './conflictResolver.js';
|
||||
import { OBJECT_TYPES, getAttributeDefinition } from '../generated/jira-schema.js';
|
||||
import type { CMDBObject, CMDBObjectTypeName, ObjectReference } from '../generated/jira-types.js';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface GetObjectOptions {
|
||||
/** Force refresh from Jira (bypasses cache) */
|
||||
forceRefresh?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateResult {
|
||||
success: boolean;
|
||||
data?: CMDBObject;
|
||||
conflict?: ConflictCheckResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Service Implementation
|
||||
// =============================================================================
|
||||
|
||||
class CMDBService {
|
||||
// ==========================================================================
|
||||
// Read Operations
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get a single object by ID
|
||||
* By default reads from cache; use forceRefresh to fetch from Jira
|
||||
*/
|
||||
async getObject<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 = 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) {
|
||||
cacheStore.upsertObject(typeName, parsed);
|
||||
cacheStore.extractAndStoreRelations(typeName, parsed);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
const cached = 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) {
|
||||
cacheStore.upsertObject(typeName, parsed);
|
||||
cacheStore.extractAndStoreRelations(typeName, parsed);
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
// If object was deleted from Jira, remove it from our cache
|
||||
if (error instanceof JiraObjectNotFoundError) {
|
||||
const deleted = cacheStore.deleteObject(typeName, id);
|
||||
if (deleted) {
|
||||
logger.info(`CMDBService: Removed deleted object ${typeName}/${id} from cache`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all objects of a type from cache
|
||||
*/
|
||||
async getObjects<T extends CMDBObject>(
|
||||
typeName: CMDBObjectTypeName,
|
||||
options?: SearchOptions
|
||||
): Promise<T[]> {
|
||||
if (options?.searchTerm) {
|
||||
return cacheStore.searchByLabel<T>(typeName, options.searchTerm, {
|
||||
limit: options.limit,
|
||||
offset: options.offset,
|
||||
});
|
||||
}
|
||||
|
||||
return cacheStore.getObjects<T>(typeName, {
|
||||
limit: options?.limit,
|
||||
offset: options?.offset,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Count objects of a type in cache
|
||||
*/
|
||||
countObjects(typeName: CMDBObjectTypeName): number {
|
||||
return cacheStore.countObjects(typeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search across all object types
|
||||
*/
|
||||
async searchAllTypes(searchTerm: string, options?: { limit?: number }): Promise<CMDBObject[]> {
|
||||
return 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 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 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
|
||||
*/
|
||||
getCacheStats(): CacheStats {
|
||||
return cacheStore.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cache has data
|
||||
*/
|
||||
isCacheWarm(): boolean {
|
||||
return cacheStore.isWarm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a specific type
|
||||
*/
|
||||
clearCacheForType(typeName: CMDBObjectTypeName): void {
|
||||
cacheStore.clearObjectType(typeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear entire cache
|
||||
*/
|
||||
clearCache(): void {
|
||||
cacheStore.clearAll();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// User Token Management (for OAuth)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Set user token for current request
|
||||
*/
|
||||
setUserToken(token: string | null): void {
|
||||
jiraAssetsClient.setRequestToken(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear user token
|
||||
*/
|
||||
clearUserToken(): void {
|
||||
jiraAssetsClient.clearRequestToken();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const cmdbService = new CMDBService();
|
||||
|
||||
Reference in New Issue
Block a user