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:
982
backend/scripts/generate-schema.ts
Normal file
982
backend/scripts/generate-schema.ts
Normal file
@@ -0,0 +1,982 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Schema Generator - Fetches Jira Assets schema DYNAMICALLY and generates:
|
||||
* - TypeScript types (jira-types.ts)
|
||||
* - Schema metadata (jira-schema.ts)
|
||||
* - Database schema (db-schema.sql)
|
||||
*
|
||||
* This script connects to the Jira Assets API to discover ALL object types
|
||||
* and their attributes, ensuring the data model is always in sync with the
|
||||
* actual CMDB configuration.
|
||||
*
|
||||
* Usage: npm run generate-schema
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
// Try multiple possible .env locations
|
||||
const envPaths = [
|
||||
path.resolve(__dirname, '../../.env'), // backend/.env
|
||||
path.resolve(__dirname, '../../../.env'), // project root .env
|
||||
];
|
||||
let envLoaded = '';
|
||||
for (const envPath of envPaths) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
envLoaded = envPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration
|
||||
const JIRA_HOST = process.env.JIRA_HOST || '';
|
||||
const JIRA_PAT = process.env.JIRA_PAT || '';
|
||||
const JIRA_SCHEMA_ID = process.env.JIRA_SCHEMA_ID || '';
|
||||
|
||||
const OUTPUT_DIR = path.resolve(__dirname, '../src/generated');
|
||||
|
||||
// =============================================================================
|
||||
// Interfaces
|
||||
// =============================================================================
|
||||
|
||||
interface JiraObjectSchema {
|
||||
id: number;
|
||||
name: string;
|
||||
objectSchemaKey: string;
|
||||
description?: string;
|
||||
objectCount?: number;
|
||||
}
|
||||
|
||||
interface JiraObjectType {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
iconId?: number;
|
||||
objectCount?: number;
|
||||
parentObjectTypeId?: number;
|
||||
objectSchemaId: number;
|
||||
inherited?: boolean;
|
||||
abstractObjectType?: boolean;
|
||||
parentObjectTypeInherited?: boolean;
|
||||
}
|
||||
|
||||
interface JiraAttribute {
|
||||
id: number;
|
||||
name: string;
|
||||
label?: string;
|
||||
type: number;
|
||||
typeValue?: string;
|
||||
defaultType?: { id: number; name: string };
|
||||
referenceObjectTypeId?: number;
|
||||
referenceObjectType?: { id: number; name: string };
|
||||
referenceType?: { id: number; name: string };
|
||||
minimumCardinality?: number;
|
||||
maximumCardinality?: number;
|
||||
editable?: boolean;
|
||||
hidden?: boolean;
|
||||
system?: boolean;
|
||||
options?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface GeneratedAttribute {
|
||||
jiraId: number;
|
||||
name: string;
|
||||
fieldName: string;
|
||||
type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown';
|
||||
isMultiple: boolean;
|
||||
isEditable: boolean;
|
||||
isRequired: boolean;
|
||||
isSystem: boolean;
|
||||
referenceTypeId?: number;
|
||||
referenceTypeName?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface GeneratedObjectType {
|
||||
jiraTypeId: number;
|
||||
name: string;
|
||||
typeName: string;
|
||||
syncPriority: number;
|
||||
objectCount: number;
|
||||
attributes: GeneratedAttribute[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Jira Type Mapping
|
||||
// =============================================================================
|
||||
|
||||
// Jira attribute type mappings (based on Jira Insight/Assets API)
|
||||
// See: https://docs.atlassian.com/jira-servicemanagement-docs/REST/5.x/insight/1.0/objecttypeattribute
|
||||
const JIRA_TYPE_MAP: Record<number, GeneratedAttribute['type']> = {
|
||||
0: 'text', // Default/Text
|
||||
1: 'integer', // Integer
|
||||
2: 'boolean', // Boolean
|
||||
3: 'float', // Double/Float
|
||||
4: 'date', // Date
|
||||
5: 'datetime', // DateTime
|
||||
6: 'url', // URL
|
||||
7: 'email', // Email
|
||||
8: 'textarea', // Textarea
|
||||
9: 'select', // Select
|
||||
10: 'reference', // Reference (Object)
|
||||
11: 'user', // User
|
||||
12: 'reference', // Confluence (treated as reference)
|
||||
13: 'reference', // Group (treated as reference)
|
||||
14: 'reference', // Version (treated as reference)
|
||||
15: 'reference', // Project (treated as reference)
|
||||
16: 'status', // Status
|
||||
};
|
||||
|
||||
// Priority types - these sync first as they are reference data
|
||||
const PRIORITY_TYPE_NAMES = new Set([
|
||||
'Application Component',
|
||||
'Server',
|
||||
'Flows',
|
||||
]);
|
||||
|
||||
// Reference data types - these sync with lower priority
|
||||
const REFERENCE_TYPE_PATTERNS = [
|
||||
/Factor$/,
|
||||
/Model$/,
|
||||
/Type$/,
|
||||
/Category$/,
|
||||
/Importance$/,
|
||||
/Analyse$/,
|
||||
/Organisation$/,
|
||||
/Function$/,
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Jira Schema Fetcher
|
||||
// =============================================================================
|
||||
|
||||
class JiraSchemaFetcher {
|
||||
private baseUrl: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor(host: string, pat: string) {
|
||||
this.baseUrl = `${host}/rest/insight/1.0`;
|
||||
this.headers = {
|
||||
'Authorization': `Bearer ${pat}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch schema info
|
||||
*/
|
||||
async fetchSchema(schemaId: string): Promise<JiraObjectSchema | null> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/objectschema/${schemaId}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch schema ${schemaId}: ${response.status} ${response.statusText}`);
|
||||
const text = await response.text();
|
||||
console.error(`Response: ${text}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Error fetching schema ${schemaId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch ALL object types in the schema
|
||||
*/
|
||||
async fetchAllObjectTypes(schemaId: string): Promise<JiraObjectType[]> {
|
||||
try {
|
||||
// Try the objecttypes/flat endpoint first (returns all types in flat structure)
|
||||
let response = await fetch(`${this.baseUrl}/objectschema/${schemaId}/objecttypes/flat`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Fallback to regular objecttypes endpoint
|
||||
response = await fetch(`${this.baseUrl}/objectschema/${schemaId}/objecttypes`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch object types: ${response.status} ${response.statusText}`);
|
||||
const text = await response.text();
|
||||
console.error(`Response: ${text}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Handle both array and object responses
|
||||
if (Array.isArray(result)) {
|
||||
return result;
|
||||
} else if (result.objectTypes) {
|
||||
return result.objectTypes;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`Error fetching object types:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch attributes for a specific object type
|
||||
*/
|
||||
async fetchAttributes(typeId: number): Promise<JiraAttribute[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/objecttype/${typeId}/attributes`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch attributes for type ${typeId}: ${response.status}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Error fetching attributes for type ${typeId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the connection
|
||||
*/
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/objectschema/list`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Utility Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Convert a string to camelCase while preserving existing casing patterns
|
||||
* E.g., "Application Function" -> "applicationFunction"
|
||||
* "ICT Governance Model" -> "ictGovernanceModel"
|
||||
* "ApplicationFunction" -> "applicationFunction"
|
||||
*/
|
||||
function toCamelCase(str: string): string {
|
||||
// First split on spaces and special chars
|
||||
const words = str
|
||||
.replace(/[^a-zA-Z0-9\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(w => w.length > 0);
|
||||
|
||||
if (words.length === 0) return '';
|
||||
|
||||
// If it's a single word that's already camelCase or PascalCase, just lowercase first char
|
||||
if (words.length === 1) {
|
||||
const word = words[0];
|
||||
return word.charAt(0).toLowerCase() + word.slice(1);
|
||||
}
|
||||
|
||||
// Multiple words - first word lowercase, rest capitalize first letter
|
||||
return words
|
||||
.map((word, index) => {
|
||||
if (index === 0) {
|
||||
// First word: if all uppercase (acronym), lowercase it, otherwise just lowercase first char
|
||||
if (word === word.toUpperCase() && word.length > 1) {
|
||||
return word.toLowerCase();
|
||||
}
|
||||
return word.charAt(0).toLowerCase() + word.slice(1);
|
||||
}
|
||||
// Other words: capitalize first letter, keep rest as-is
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to PascalCase while preserving existing casing patterns
|
||||
* E.g., "Application Function" -> "ApplicationFunction"
|
||||
* "ICT Governance Model" -> "IctGovernanceModel"
|
||||
* "applicationFunction" -> "ApplicationFunction"
|
||||
*/
|
||||
function toPascalCase(str: string): string {
|
||||
// First split on spaces and special chars
|
||||
const words = str
|
||||
.replace(/[^a-zA-Z0-9\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(w => w.length > 0);
|
||||
|
||||
if (words.length === 0) return '';
|
||||
|
||||
// If it's a single word, just capitalize first letter
|
||||
if (words.length === 1) {
|
||||
const word = words[0];
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}
|
||||
|
||||
// Multiple words - capitalize first letter of each
|
||||
return words
|
||||
.map(word => {
|
||||
// If all uppercase (acronym) and first word, just capitalize first letter
|
||||
if (word === word.toUpperCase() && word.length > 1) {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
||||
}
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function mapJiraType(typeId: number): GeneratedAttribute['type'] {
|
||||
return JIRA_TYPE_MAP[typeId] || 'unknown';
|
||||
}
|
||||
|
||||
function determineSyncPriority(typeName: string, objectCount: number): number {
|
||||
// Application Component and related main types first
|
||||
if (PRIORITY_TYPE_NAMES.has(typeName)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Reference data types last
|
||||
for (const pattern of REFERENCE_TYPE_PATTERNS) {
|
||||
if (pattern.test(typeName)) {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Medium priority for types with more objects
|
||||
if (objectCount > 100) {
|
||||
return 2;
|
||||
}
|
||||
if (objectCount > 10) {
|
||||
return 5;
|
||||
}
|
||||
|
||||
return 8;
|
||||
}
|
||||
|
||||
function parseAttribute(
|
||||
attr: JiraAttribute,
|
||||
allTypeConfigs: Map<number, { name: string; typeName: string }>
|
||||
): GeneratedAttribute {
|
||||
const typeId = attr.type || attr.defaultType?.id || 0;
|
||||
let type = mapJiraType(typeId);
|
||||
const isMultiple = (attr.maximumCardinality ?? 1) > 1 || attr.maximumCardinality === -1;
|
||||
const isEditable = attr.editable !== false && !attr.hidden;
|
||||
const isRequired = (attr.minimumCardinality ?? 0) > 0;
|
||||
const isSystem = attr.system === true;
|
||||
|
||||
// CRITICAL: Jira sometimes returns type=1 (integer) for reference attributes!
|
||||
// The presence of referenceObjectTypeId is the true indicator of a reference type.
|
||||
const refTypeId = attr.referenceObjectTypeId || attr.referenceObjectType?.id || attr.referenceType?.id;
|
||||
if (refTypeId) {
|
||||
type = 'reference';
|
||||
}
|
||||
|
||||
const result: GeneratedAttribute = {
|
||||
jiraId: attr.id,
|
||||
name: attr.name,
|
||||
fieldName: toCamelCase(attr.name),
|
||||
type,
|
||||
isMultiple,
|
||||
isEditable,
|
||||
isRequired,
|
||||
isSystem,
|
||||
description: attr.description,
|
||||
};
|
||||
|
||||
// Handle reference types - add reference metadata
|
||||
if (type === 'reference' && refTypeId) {
|
||||
result.referenceTypeId = refTypeId;
|
||||
const refConfig = allTypeConfigs.get(refTypeId);
|
||||
result.referenceTypeName = refConfig?.typeName ||
|
||||
attr.referenceObjectType?.name ||
|
||||
attr.referenceType?.name ||
|
||||
`Type${refTypeId}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Code Generation Functions
|
||||
// =============================================================================
|
||||
|
||||
function generateTypeScriptType(attrType: GeneratedAttribute['type'], isMultiple: boolean, isReference: boolean): string {
|
||||
let tsType: string;
|
||||
|
||||
if (isReference) {
|
||||
tsType = 'ObjectReference';
|
||||
} else {
|
||||
switch (attrType) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
case 'url':
|
||||
case 'email':
|
||||
case 'select':
|
||||
case 'user':
|
||||
case 'status':
|
||||
tsType = 'string';
|
||||
break;
|
||||
case 'integer':
|
||||
case 'float':
|
||||
tsType = 'number';
|
||||
break;
|
||||
case 'boolean':
|
||||
tsType = 'boolean';
|
||||
break;
|
||||
case 'date':
|
||||
case 'datetime':
|
||||
tsType = 'string'; // ISO date string
|
||||
break;
|
||||
default:
|
||||
tsType = 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
if (isMultiple) {
|
||||
return `${tsType}[]`;
|
||||
}
|
||||
return `${tsType} | null`;
|
||||
}
|
||||
|
||||
function generateTypesFile(objectTypes: GeneratedObjectType[], generatedAt: Date): string {
|
||||
const lines: string[] = [
|
||||
'// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY',
|
||||
'// Generated from Jira Assets Schema via REST API',
|
||||
`// Generated at: ${generatedAt.toISOString()}`,
|
||||
'//',
|
||||
'// Re-generate with: npm run generate-schema',
|
||||
'',
|
||||
'// =============================================================================',
|
||||
'// Base Types',
|
||||
'// =============================================================================',
|
||||
'',
|
||||
'/** Reference to another CMDB object */',
|
||||
'export interface ObjectReference {',
|
||||
' objectId: string;',
|
||||
' objectKey: string;',
|
||||
' label: string;',
|
||||
' // Optional enriched data from referenced object',
|
||||
' factor?: number;',
|
||||
'}',
|
||||
'',
|
||||
'/** Base interface for all CMDB objects */',
|
||||
'export interface BaseCMDBObject {',
|
||||
' id: string;',
|
||||
' objectKey: string;',
|
||||
' label: string;',
|
||||
' _objectType: string;',
|
||||
' _jiraUpdatedAt: string;',
|
||||
' _jiraCreatedAt: string;',
|
||||
'}',
|
||||
'',
|
||||
'// =============================================================================',
|
||||
'// Object Type Interfaces',
|
||||
'// =============================================================================',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const objType of objectTypes) {
|
||||
lines.push(`/** ${objType.name} (Jira Type ID: ${objType.jiraTypeId}, ${objType.objectCount} objects) */`);
|
||||
lines.push(`export interface ${objType.typeName} extends BaseCMDBObject {`);
|
||||
lines.push(` _objectType: '${objType.typeName}';`);
|
||||
lines.push('');
|
||||
|
||||
// Group attributes by type
|
||||
const scalarAttrs = objType.attributes.filter(a => a.type !== 'reference');
|
||||
const refAttrs = objType.attributes.filter(a => a.type === 'reference');
|
||||
|
||||
if (scalarAttrs.length > 0) {
|
||||
lines.push(' // Scalar attributes');
|
||||
for (const attr of scalarAttrs) {
|
||||
const tsType = generateTypeScriptType(attr.type, attr.isMultiple, false);
|
||||
const comment = attr.description ? ` // ${attr.description}` : '';
|
||||
lines.push(` ${attr.fieldName}: ${tsType};${comment}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (refAttrs.length > 0) {
|
||||
lines.push(' // Reference attributes');
|
||||
for (const attr of refAttrs) {
|
||||
const tsType = generateTypeScriptType(attr.type, attr.isMultiple, true);
|
||||
const comment = attr.referenceTypeName ? ` // -> ${attr.referenceTypeName}` : '';
|
||||
lines.push(` ${attr.fieldName}: ${tsType};${comment}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Generate union type
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('// Union Types');
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('');
|
||||
lines.push('/** Union of all CMDB object types */');
|
||||
lines.push('export type CMDBObject =');
|
||||
for (let i = 0; i < objectTypes.length; i++) {
|
||||
const suffix = i < objectTypes.length - 1 ? '' : ';';
|
||||
lines.push(` | ${objectTypes[i].typeName}${suffix}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Generate type name literal union
|
||||
lines.push('/** All valid object type names */');
|
||||
lines.push('export type CMDBObjectTypeName =');
|
||||
for (let i = 0; i < objectTypes.length; i++) {
|
||||
const suffix = i < objectTypes.length - 1 ? '' : ';';
|
||||
lines.push(` | '${objectTypes[i].typeName}'${suffix}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Generate type guards
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('// Type Guards');
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('');
|
||||
for (const objType of objectTypes) {
|
||||
lines.push(`export function is${objType.typeName}(obj: CMDBObject): obj is ${objType.typeName} {`);
|
||||
lines.push(` return obj._objectType === '${objType.typeName}';`);
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function generateSchemaFile(objectTypes: GeneratedObjectType[], generatedAt: Date): string {
|
||||
const lines: string[] = [
|
||||
'// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY',
|
||||
'// Generated from Jira Assets Schema via REST API',
|
||||
`// Generated at: ${generatedAt.toISOString()}`,
|
||||
'//',
|
||||
'// Re-generate with: npm run generate-schema',
|
||||
'',
|
||||
'// =============================================================================',
|
||||
'// Schema Type Definitions',
|
||||
'// =============================================================================',
|
||||
'',
|
||||
'export interface AttributeDefinition {',
|
||||
' jiraId: number;',
|
||||
' name: string;',
|
||||
' fieldName: string;',
|
||||
" type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown';",
|
||||
' isMultiple: boolean;',
|
||||
' isEditable: boolean;',
|
||||
' isRequired: boolean;',
|
||||
' isSystem: boolean;',
|
||||
' referenceTypeId?: number;',
|
||||
' referenceTypeName?: string;',
|
||||
' description?: string;',
|
||||
'}',
|
||||
'',
|
||||
'export interface ObjectTypeDefinition {',
|
||||
' jiraTypeId: number;',
|
||||
' name: string;',
|
||||
' typeName: string;',
|
||||
' syncPriority: number;',
|
||||
' objectCount: number;',
|
||||
' attributes: AttributeDefinition[];',
|
||||
'}',
|
||||
'',
|
||||
'// =============================================================================',
|
||||
'// Schema Metadata',
|
||||
'// =============================================================================',
|
||||
'',
|
||||
`export const SCHEMA_GENERATED_AT = '${generatedAt.toISOString()}';`,
|
||||
`export const SCHEMA_OBJECT_TYPE_COUNT = ${objectTypes.length};`,
|
||||
`export const SCHEMA_TOTAL_ATTRIBUTES = ${objectTypes.reduce((sum, ot) => sum + ot.attributes.length, 0)};`,
|
||||
'',
|
||||
'// =============================================================================',
|
||||
'// Object Type Definitions',
|
||||
'// =============================================================================',
|
||||
'',
|
||||
'export const OBJECT_TYPES: Record<string, ObjectTypeDefinition> = {',
|
||||
];
|
||||
|
||||
for (let i = 0; i < objectTypes.length; i++) {
|
||||
const objType = objectTypes[i];
|
||||
const comma = i < objectTypes.length - 1 ? ',' : '';
|
||||
|
||||
lines.push(` '${objType.typeName}': {`);
|
||||
lines.push(` jiraTypeId: ${objType.jiraTypeId},`);
|
||||
lines.push(` name: '${objType.name.replace(/'/g, "\\'")}',`);
|
||||
lines.push(` typeName: '${objType.typeName}',`);
|
||||
lines.push(` syncPriority: ${objType.syncPriority},`);
|
||||
lines.push(` objectCount: ${objType.objectCount},`);
|
||||
lines.push(' attributes: [');
|
||||
|
||||
for (let j = 0; j < objType.attributes.length; j++) {
|
||||
const attr = objType.attributes[j];
|
||||
const attrComma = j < objType.attributes.length - 1 ? ',' : '';
|
||||
|
||||
let attrLine = ` { jiraId: ${attr.jiraId}, name: '${attr.name.replace(/'/g, "\\'")}', fieldName: '${attr.fieldName}', type: '${attr.type}', isMultiple: ${attr.isMultiple}, isEditable: ${attr.isEditable}, isRequired: ${attr.isRequired}, isSystem: ${attr.isSystem}`;
|
||||
|
||||
if (attr.referenceTypeId) {
|
||||
attrLine += `, referenceTypeId: ${attr.referenceTypeId}, referenceTypeName: '${attr.referenceTypeName}'`;
|
||||
}
|
||||
if (attr.description) {
|
||||
attrLine += `, description: '${attr.description.replace(/'/g, "\\'").replace(/\n/g, ' ')}'`;
|
||||
}
|
||||
|
||||
attrLine += ` }${attrComma}`;
|
||||
lines.push(attrLine);
|
||||
}
|
||||
|
||||
lines.push(' ],');
|
||||
lines.push(` }${comma}`);
|
||||
}
|
||||
|
||||
lines.push('};');
|
||||
lines.push('');
|
||||
|
||||
// Generate lookup maps
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('// Lookup Maps');
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('');
|
||||
|
||||
// Type ID to name map
|
||||
lines.push('/** Map from Jira Type ID to TypeScript type name */');
|
||||
lines.push('export const TYPE_ID_TO_NAME: Record<number, string> = {');
|
||||
for (const objType of objectTypes) {
|
||||
lines.push(` ${objType.jiraTypeId}: '${objType.typeName}',`);
|
||||
}
|
||||
lines.push('};');
|
||||
lines.push('');
|
||||
|
||||
// Type name to ID map
|
||||
lines.push('/** Map from TypeScript type name to Jira Type ID */');
|
||||
lines.push('export const TYPE_NAME_TO_ID: Record<string, number> = {');
|
||||
for (const objType of objectTypes) {
|
||||
lines.push(` '${objType.typeName}': ${objType.jiraTypeId},`);
|
||||
}
|
||||
lines.push('};');
|
||||
lines.push('');
|
||||
|
||||
// Jira name to TypeScript name map
|
||||
lines.push('/** Map from Jira object type name to TypeScript type name */');
|
||||
lines.push('export const JIRA_NAME_TO_TYPE: Record<string, string> = {');
|
||||
for (const objType of objectTypes) {
|
||||
lines.push(` '${objType.name.replace(/'/g, "\\'")}': '${objType.typeName}',`);
|
||||
}
|
||||
lines.push('};');
|
||||
lines.push('');
|
||||
|
||||
// Helper functions
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('// Helper Functions');
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('');
|
||||
|
||||
lines.push('/** Get attribute definition by type and field name */');
|
||||
lines.push('export function getAttributeDefinition(typeName: string, fieldName: string): AttributeDefinition | undefined {');
|
||||
lines.push(' const objectType = OBJECT_TYPES[typeName];');
|
||||
lines.push(' if (!objectType) return undefined;');
|
||||
lines.push(' return objectType.attributes.find(a => a.fieldName === fieldName);');
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
lines.push('/** Get attribute definition by type and Jira attribute ID */');
|
||||
lines.push('export function getAttributeById(typeName: string, jiraId: number): AttributeDefinition | undefined {');
|
||||
lines.push(' const objectType = OBJECT_TYPES[typeName];');
|
||||
lines.push(' if (!objectType) return undefined;');
|
||||
lines.push(' return objectType.attributes.find(a => a.jiraId === jiraId);');
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
lines.push('/** Get attribute definition by type and Jira attribute name */');
|
||||
lines.push('export function getAttributeByName(typeName: string, attrName: string): AttributeDefinition | undefined {');
|
||||
lines.push(' const objectType = OBJECT_TYPES[typeName];');
|
||||
lines.push(' if (!objectType) return undefined;');
|
||||
lines.push(' return objectType.attributes.find(a => a.name === attrName);');
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
lines.push('/** Get attribute Jira ID by type and attribute name - throws if not found */');
|
||||
lines.push('export function getAttributeId(typeName: string, attrName: string): number {');
|
||||
lines.push(' const attr = getAttributeByName(typeName, attrName);');
|
||||
lines.push(' if (!attr) {');
|
||||
lines.push(' throw new Error(`Attribute "${attrName}" not found on type "${typeName}"`);');
|
||||
lines.push(' }');
|
||||
lines.push(' return attr.jiraId;');
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
lines.push('/** Get all reference attributes for a type */');
|
||||
lines.push('export function getReferenceAttributes(typeName: string): AttributeDefinition[] {');
|
||||
lines.push(' const objectType = OBJECT_TYPES[typeName];');
|
||||
lines.push(' if (!objectType) return [];');
|
||||
lines.push(" return objectType.attributes.filter(a => a.type === 'reference');");
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
lines.push('/** Get all object types sorted by sync priority */');
|
||||
lines.push('export function getObjectTypesBySyncPriority(): ObjectTypeDefinition[] {');
|
||||
lines.push(' return Object.values(OBJECT_TYPES).sort((a, b) => a.syncPriority - b.syncPriority);');
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function generateDatabaseSchema(generatedAt: Date): string {
|
||||
const lines: string[] = [
|
||||
'-- AUTO-GENERATED FILE - DO NOT EDIT MANUALLY',
|
||||
'-- Generated from Jira Assets Schema via REST API',
|
||||
`-- Generated at: ${generatedAt.toISOString()}`,
|
||||
'--',
|
||||
'-- Re-generate with: npm run generate-schema',
|
||||
'',
|
||||
'-- =============================================================================',
|
||||
'-- Core Tables',
|
||||
'-- =============================================================================',
|
||||
'',
|
||||
'-- Cached CMDB objects (all types stored in single table with JSON data)',
|
||||
'CREATE TABLE IF NOT EXISTS cached_objects (',
|
||||
' id TEXT PRIMARY KEY,',
|
||||
' object_key TEXT NOT NULL UNIQUE,',
|
||||
' object_type TEXT NOT NULL,',
|
||||
' label TEXT NOT NULL,',
|
||||
' data JSON NOT NULL,',
|
||||
' jira_updated_at TEXT,',
|
||||
' jira_created_at TEXT,',
|
||||
' cached_at TEXT NOT NULL',
|
||||
');',
|
||||
'',
|
||||
'-- Object relations (references between objects)',
|
||||
'CREATE TABLE IF NOT EXISTS object_relations (',
|
||||
' id INTEGER PRIMARY KEY AUTOINCREMENT,',
|
||||
' source_id TEXT NOT NULL,',
|
||||
' target_id TEXT NOT NULL,',
|
||||
' attribute_name TEXT NOT NULL,',
|
||||
' source_type TEXT NOT NULL,',
|
||||
' target_type TEXT NOT NULL,',
|
||||
' UNIQUE(source_id, target_id, attribute_name)',
|
||||
');',
|
||||
'',
|
||||
'-- Sync metadata (tracks sync state)',
|
||||
'CREATE TABLE IF NOT EXISTS sync_metadata (',
|
||||
' key TEXT PRIMARY KEY,',
|
||||
' value TEXT NOT NULL,',
|
||||
' updated_at TEXT NOT NULL',
|
||||
');',
|
||||
'',
|
||||
'-- =============================================================================',
|
||||
'-- Indices for Performance',
|
||||
'-- =============================================================================',
|
||||
'',
|
||||
'CREATE INDEX IF NOT EXISTS idx_objects_type ON cached_objects(object_type);',
|
||||
'CREATE INDEX IF NOT EXISTS idx_objects_key ON cached_objects(object_key);',
|
||||
'CREATE INDEX IF NOT EXISTS idx_objects_updated ON cached_objects(jira_updated_at);',
|
||||
'CREATE INDEX IF NOT EXISTS idx_objects_label ON cached_objects(label);',
|
||||
'',
|
||||
'CREATE INDEX IF NOT EXISTS idx_relations_source ON object_relations(source_id);',
|
||||
'CREATE INDEX IF NOT EXISTS idx_relations_target ON object_relations(target_id);',
|
||||
'CREATE INDEX IF NOT EXISTS idx_relations_source_type ON object_relations(source_type);',
|
||||
'CREATE INDEX IF NOT EXISTS idx_relations_target_type ON object_relations(target_type);',
|
||||
'CREATE INDEX IF NOT EXISTS idx_relations_attr ON object_relations(attribute_name);',
|
||||
'',
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main
|
||||
// =============================================================================
|
||||
|
||||
async function main() {
|
||||
const generatedAt = new Date();
|
||||
|
||||
console.log('');
|
||||
console.log('╔════════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ CMDB Schema Generator - Jira Assets API ║');
|
||||
console.log('╚════════════════════════════════════════════════════════════════╝');
|
||||
console.log('');
|
||||
|
||||
// Validate configuration
|
||||
if (!JIRA_HOST) {
|
||||
console.error('❌ ERROR: JIRA_HOST environment variable is required');
|
||||
console.error(' Set this in your .env file: JIRA_HOST=https://jira.your-domain.com');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!JIRA_PAT) {
|
||||
console.error('❌ ERROR: JIRA_PAT environment variable is required');
|
||||
console.error(' Set this in your .env file: JIRA_PAT=your-personal-access-token');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!JIRA_SCHEMA_ID) {
|
||||
console.error('❌ ERROR: JIRA_SCHEMA_ID environment variable is required');
|
||||
console.error(' Set this in your .env file: JIRA_SCHEMA_ID=6');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (envLoaded) {
|
||||
console.log(`🔧 Environment: ${envLoaded}`);
|
||||
}
|
||||
console.log(`📡 Jira Host: ${JIRA_HOST}`);
|
||||
console.log(`📋 Schema ID: ${JIRA_SCHEMA_ID}`);
|
||||
console.log(`📁 Output Dir: ${OUTPUT_DIR}`);
|
||||
console.log('');
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
console.log(`📁 Created output directory: ${OUTPUT_DIR}`);
|
||||
}
|
||||
|
||||
const fetcher = new JiraSchemaFetcher(JIRA_HOST, JIRA_PAT);
|
||||
|
||||
// Test connection
|
||||
console.log('🔌 Testing connection to Jira Assets API...');
|
||||
const connected = await fetcher.testConnection();
|
||||
if (!connected) {
|
||||
console.error('❌ Failed to connect to Jira Assets API');
|
||||
console.error(' Please check your JIRA_HOST and JIRA_PAT settings');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✅ Connection successful');
|
||||
console.log('');
|
||||
|
||||
// Fetch schema info
|
||||
console.log('📋 Fetching schema information...');
|
||||
const schema = await fetcher.fetchSchema(JIRA_SCHEMA_ID);
|
||||
if (!schema) {
|
||||
console.error(`❌ Failed to fetch schema ${JIRA_SCHEMA_ID}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` Schema: ${schema.name} (${schema.objectSchemaKey})`);
|
||||
console.log(` Total objects: ${schema.objectCount || 'unknown'}`);
|
||||
console.log('');
|
||||
|
||||
// Fetch ALL object types from the schema
|
||||
console.log('📦 Fetching all object types from schema...');
|
||||
const allObjectTypes = await fetcher.fetchAllObjectTypes(JIRA_SCHEMA_ID);
|
||||
|
||||
if (allObjectTypes.length === 0) {
|
||||
console.error('❌ No object types found in schema');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(` Found ${allObjectTypes.length} object types`);
|
||||
console.log('');
|
||||
|
||||
// Build a map of all type IDs to names for reference resolution
|
||||
const typeConfigs = new Map<number, { name: string; typeName: string }>();
|
||||
for (const ot of allObjectTypes) {
|
||||
typeConfigs.set(ot.id, {
|
||||
name: ot.name,
|
||||
typeName: toPascalCase(ot.name),
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch attributes for each object type
|
||||
console.log('🔍 Fetching attributes for each object type:');
|
||||
console.log('');
|
||||
|
||||
const generatedTypes: GeneratedObjectType[] = [];
|
||||
let totalAttributes = 0;
|
||||
|
||||
for (const objType of allObjectTypes) {
|
||||
const typeName = toPascalCase(objType.name);
|
||||
process.stdout.write(` ${objType.name.padEnd(50)} `);
|
||||
|
||||
const attributes = await fetcher.fetchAttributes(objType.id);
|
||||
|
||||
if (attributes.length === 0) {
|
||||
console.log('⚠️ (no attributes)');
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedAttributes = attributes.map(attr => parseAttribute(attr, typeConfigs));
|
||||
const syncPriority = determineSyncPriority(objType.name, objType.objectCount || 0);
|
||||
|
||||
generatedTypes.push({
|
||||
jiraTypeId: objType.id,
|
||||
name: objType.name,
|
||||
typeName,
|
||||
syncPriority,
|
||||
objectCount: objType.objectCount || 0,
|
||||
attributes: parsedAttributes,
|
||||
});
|
||||
|
||||
totalAttributes += attributes.length;
|
||||
console.log(`✅ ${attributes.length} attributes`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`📊 Summary: ${generatedTypes.length} types, ${totalAttributes} attributes total`);
|
||||
console.log('');
|
||||
|
||||
// Sort by sync priority
|
||||
generatedTypes.sort((a, b) => a.syncPriority - b.syncPriority);
|
||||
|
||||
// Generate files
|
||||
console.log('📝 Generating output files:');
|
||||
console.log('');
|
||||
|
||||
// 1. TypeScript types
|
||||
const typesContent = generateTypesFile(generatedTypes, generatedAt);
|
||||
const typesPath = path.join(OUTPUT_DIR, 'jira-types.ts');
|
||||
fs.writeFileSync(typesPath, typesContent, 'utf-8');
|
||||
console.log(` ✅ ${typesPath}`);
|
||||
|
||||
// 2. Schema metadata
|
||||
const schemaContent = generateSchemaFile(generatedTypes, generatedAt);
|
||||
const schemaPath = path.join(OUTPUT_DIR, 'jira-schema.ts');
|
||||
fs.writeFileSync(schemaPath, schemaContent, 'utf-8');
|
||||
console.log(` ✅ ${schemaPath}`);
|
||||
|
||||
// 3. Database DDL
|
||||
const dbContent = generateDatabaseSchema(generatedAt);
|
||||
const dbPath = path.join(OUTPUT_DIR, 'db-schema.sql');
|
||||
fs.writeFileSync(dbPath, dbContent, 'utf-8');
|
||||
console.log(` ✅ ${dbPath}`);
|
||||
|
||||
console.log('');
|
||||
console.log('╔════════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ Schema generation complete! ║');
|
||||
console.log('╚════════════════════════════════════════════════════════════════╝');
|
||||
console.log('');
|
||||
console.log(`Generated at: ${generatedAt.toISOString()}`);
|
||||
console.log(`Object types: ${generatedTypes.length}`);
|
||||
console.log(`Attributes: ${totalAttributes}`);
|
||||
console.log('');
|
||||
console.log('Next steps:');
|
||||
console.log(' 1. Review the generated files in src/generated/');
|
||||
console.log(' 2. Rebuild the application: npm run build');
|
||||
console.log(' 3. Restart the server to pick up the new schema');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Run
|
||||
main().catch(error => {
|
||||
console.error('');
|
||||
console.error('❌ Schema generation failed:', error);
|
||||
console.error('');
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user