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:
2026-01-21 03:24:56 +01:00
parent e276e77fbc
commit cdee0e8819
138 changed files with 24551 additions and 3352 deletions

View File

@@ -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;
}
}