UI styling improvements: dashboard headers and navigation
- Restore blue PageHeader on Dashboard (/app-components) - Update homepage (/) with subtle header design without blue bar - Add uniform PageHeader styling to application edit page - Fix Rapporten link on homepage to point to /reports overview - Improve header descriptions spacing for better readability
This commit is contained in:
@@ -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<number, CMDBObjectTypeName> = {};
|
||||
const JIRA_NAME_TO_TYPE: Record<string, CMDBObjectTypeName> = {};
|
||||
// Lookup maps - will be populated dynamically from database schema
|
||||
let TYPE_ID_TO_NAME: Record<number, string> = {};
|
||||
let JIRA_NAME_TO_TYPE: Record<string, string> = {};
|
||||
let OBJECT_TYPES_CACHE: Record<string, { jiraTypeId: number; name: string; attributes: Array<{ jiraId: number; name: string; fieldName: string; type: string; isMultiple?: boolean }> }> = {};
|
||||
|
||||
// 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<void> {
|
||||
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<JiraAssetsObject | null> {
|
||||
/**
|
||||
* Get raw ObjectEntry for an object (for recursive processing)
|
||||
*/
|
||||
async getObjectEntry(objectId: string): Promise<ObjectEntry | null> {
|
||||
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<JiraAssetsObject>(url, {}, false); // Read operation
|
||||
const url = `/object/${objectId}?includeAttributes=true&includeAttributesDeep=2`;
|
||||
const entry = await this.request<ObjectEntry>(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<JiraAssetsObject | null> {
|
||||
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<JiraAssetsSearchResponse>(`/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<JiraAssetsSearchResponse>(`/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<JiraAssetsSearchResponse>('/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<string>,
|
||||
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<number> {
|
||||
const typeDef = OBJECT_TYPES[typeName];
|
||||
async getObjectCount(typeName: string, schemaId?: string): Promise<number> {
|
||||
// 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<JiraAssetsObject[]> {
|
||||
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<T extends CMDBObject>(jiraObj: JiraAssetsObject): T | null {
|
||||
async parseObject<T extends CMDBObject>(jiraObj: JiraAssetsObject): Promise<T | null> {
|
||||
// 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<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user