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:
533
backend/src/api/controllers/DebugController.ts
Normal file
533
backend/src/api/controllers/DebugController.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* DebugController - Debug/testing endpoints for architecture validation
|
||||
*
|
||||
* Provides endpoints to run SQL queries and check database state for testing.
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { logger } from '../../services/logger.js';
|
||||
import { getServices } from '../../services/ServiceFactory.js';
|
||||
|
||||
export class DebugController {
|
||||
/**
|
||||
* Execute a SQL query (read-only for safety)
|
||||
* POST /api/v2/debug/query
|
||||
* Body: { sql: string, params?: any[] }
|
||||
*/
|
||||
async executeQuery(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { sql, params = [] } = req.body;
|
||||
|
||||
if (!sql || typeof sql !== 'string') {
|
||||
res.status(400).json({ error: 'SQL query required in request body' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Safety check: only allow SELECT queries
|
||||
const normalizedSql = sql.trim().toUpperCase();
|
||||
if (!normalizedSql.startsWith('SELECT')) {
|
||||
res.status(400).json({ error: 'Only SELECT queries are allowed for security' });
|
||||
return;
|
||||
}
|
||||
|
||||
const services = getServices();
|
||||
const db = services.cacheRepo.db;
|
||||
|
||||
const result = await db.query(sql, params);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
rowCount: result.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('DebugController: Query execution failed', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get object info (ID, key, type) for debugging
|
||||
* GET /api/v2/debug/objects?objectKey=...
|
||||
*/
|
||||
async getObjectInfo(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const objectKey = req.query.objectKey as string;
|
||||
if (!objectKey) {
|
||||
res.status(400).json({ error: 'objectKey query parameter required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const services = getServices();
|
||||
const obj = await services.cacheRepo.getObjectByKey(objectKey);
|
||||
|
||||
if (!obj) {
|
||||
res.status(404).json({ error: 'Object not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get attribute count
|
||||
const attrValues = await services.cacheRepo.getAttributeValues(obj.id);
|
||||
|
||||
res.json({
|
||||
object: obj,
|
||||
attributeValueCount: attrValues.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('DebugController: Failed to get object info', error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relation info for debugging
|
||||
* GET /api/v2/debug/relations?objectKey=...
|
||||
*/
|
||||
async getRelationInfo(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const objectKey = req.query.objectKey as string;
|
||||
if (!objectKey) {
|
||||
res.status(400).json({ error: 'objectKey query parameter required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const services = getServices();
|
||||
const obj = await services.cacheRepo.getObjectByKey(objectKey);
|
||||
|
||||
if (!obj) {
|
||||
res.status(404).json({ error: 'Object not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get relations where this object is source
|
||||
const sourceRelations = await services.cacheRepo.db.query<{
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
attributeId: number;
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
}>(
|
||||
`SELECT source_id as sourceId, target_id as targetId, attribute_id as attributeId,
|
||||
source_type as sourceType, target_type as targetType
|
||||
FROM object_relations
|
||||
WHERE source_id = ?`,
|
||||
[obj.id]
|
||||
);
|
||||
|
||||
// Get relations where this object is target
|
||||
const targetRelations = await services.cacheRepo.db.query<{
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
attributeId: number;
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
}>(
|
||||
`SELECT source_id as sourceId, target_id as targetId, attribute_id as attributeId,
|
||||
source_type as sourceType, target_type as targetType
|
||||
FROM object_relations
|
||||
WHERE target_id = ?`,
|
||||
[obj.id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
object: obj,
|
||||
sourceRelations: sourceRelations.length,
|
||||
targetRelations: targetRelations.length,
|
||||
relations: {
|
||||
outgoing: sourceRelations,
|
||||
incoming: targetRelations,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('DebugController: Failed to get relation info', error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get object type statistics
|
||||
* GET /api/v2/debug/object-types/:typeName/stats
|
||||
*/
|
||||
async getObjectTypeStats(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const typeName = req.params.typeName;
|
||||
const services = getServices();
|
||||
|
||||
// Get object count
|
||||
const count = await services.cacheRepo.countObjectsByType(typeName);
|
||||
|
||||
// Get sample objects
|
||||
const samples = await services.cacheRepo.getObjectsByType(typeName, { limit: 5 });
|
||||
|
||||
// Get enabled status from schema
|
||||
const typeInfo = await services.schemaRepo.getObjectTypeByTypeName(typeName);
|
||||
|
||||
res.json({
|
||||
typeName,
|
||||
objectCount: count,
|
||||
enabled: typeInfo?.enabled || false,
|
||||
sampleObjects: samples.map(o => ({
|
||||
id: o.id,
|
||||
objectKey: o.objectKey,
|
||||
label: o.label,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('DebugController: Failed to get object type stats', error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all object types with their enabled status (for debugging)
|
||||
* GET /api/v2/debug/all-object-types
|
||||
*/
|
||||
async getAllObjectTypes(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const services = getServices();
|
||||
const db = services.schemaRepo.db;
|
||||
|
||||
// Check if object_types table exists
|
||||
try {
|
||||
const tableCheck = await db.query('SELECT 1 FROM object_types LIMIT 1');
|
||||
} catch (error) {
|
||||
logger.error('DebugController: object_types table does not exist or is not accessible', error);
|
||||
res.status(500).json({
|
||||
error: 'object_types table does not exist. Please run schema sync first.',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all object types
|
||||
let allTypes: Array<{
|
||||
id: number;
|
||||
type_name: string | null;
|
||||
display_name: string;
|
||||
enabled: boolean | number;
|
||||
jira_type_id: number;
|
||||
schema_id: number;
|
||||
}>;
|
||||
|
||||
try {
|
||||
allTypes = await db.query<{
|
||||
id: number;
|
||||
type_name: string | null;
|
||||
display_name: string;
|
||||
enabled: boolean | number;
|
||||
jira_type_id: number;
|
||||
schema_id: number;
|
||||
}>(
|
||||
`SELECT id, type_name, display_name, enabled, jira_type_id, schema_id
|
||||
FROM object_types
|
||||
ORDER BY enabled DESC, type_name`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('DebugController: Failed to query object_types table', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to query object_types table',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get enabled types via service (may fail if table has issues)
|
||||
let enabledTypes: Array<{ typeName: string; displayName: string; schemaId: string; objectTypeId: number }> = [];
|
||||
try {
|
||||
enabledTypes = await services.schemaSyncService.getEnabledObjectTypes();
|
||||
logger.debug(`DebugController: getEnabledObjectTypes returned ${enabledTypes.length} types: ${enabledTypes.map(t => t.typeName).join(', ')}`);
|
||||
} catch (error) {
|
||||
logger.error('DebugController: Failed to get enabled types via service', error);
|
||||
if (error instanceof Error) {
|
||||
logger.error('Error details:', { message: error.message, stack: error.stack });
|
||||
}
|
||||
// Continue without enabled types from service
|
||||
}
|
||||
|
||||
res.json({
|
||||
allTypes: allTypes.map(t => ({
|
||||
id: t.id,
|
||||
typeName: t.type_name,
|
||||
displayName: t.display_name,
|
||||
enabled: t.enabled,
|
||||
jiraTypeId: t.jira_type_id,
|
||||
schemaId: t.schema_id,
|
||||
hasTypeName: !!(t.type_name && t.type_name.trim() !== ''),
|
||||
})),
|
||||
enabledTypes: enabledTypes.map(t => ({
|
||||
typeName: t.typeName,
|
||||
displayName: t.displayName,
|
||||
schemaId: t.schemaId,
|
||||
objectTypeId: t.objectTypeId,
|
||||
})),
|
||||
summary: {
|
||||
total: allTypes.length,
|
||||
enabled: allTypes.filter(t => {
|
||||
const isPostgres = db.isPostgres === true;
|
||||
const enabledValue = isPostgres ? (t.enabled === true) : (t.enabled === 1);
|
||||
return enabledValue && t.type_name && t.type_name.trim() !== '';
|
||||
}).length,
|
||||
enabledWithTypeName: enabledTypes.length,
|
||||
missingTypeName: allTypes.filter(t => !t.type_name || t.type_name.trim() === '').length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('DebugController: Failed to get all object types', error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnose a specific object type (check database state)
|
||||
* GET /api/v2/debug/object-types/diagnose/:typeName
|
||||
* Checks both by type_name and display_name
|
||||
*/
|
||||
async diagnoseObjectType(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const typeName = req.params.typeName;
|
||||
const services = getServices();
|
||||
const db = services.schemaRepo.db;
|
||||
const isPostgres = db.isPostgres === true;
|
||||
const enabledCondition = isPostgres ? 'enabled IS true' : 'enabled = 1';
|
||||
|
||||
// Check by type_name (exact match)
|
||||
const byTypeName = await db.query<{
|
||||
id: number;
|
||||
schema_id: number;
|
||||
jira_type_id: number;
|
||||
type_name: string | null;
|
||||
display_name: string;
|
||||
enabled: boolean | number;
|
||||
description: string | null;
|
||||
}>(
|
||||
`SELECT id, schema_id, jira_type_id, type_name, display_name, enabled, description
|
||||
FROM object_types
|
||||
WHERE type_name = ?`,
|
||||
[typeName]
|
||||
);
|
||||
|
||||
// Check by display_name (case-insensitive, partial match)
|
||||
const byDisplayName = await db.query<{
|
||||
id: number;
|
||||
schema_id: number;
|
||||
jira_type_id: number;
|
||||
type_name: string | null;
|
||||
display_name: string;
|
||||
enabled: boolean | number;
|
||||
description: string | null;
|
||||
}>(
|
||||
isPostgres
|
||||
? `SELECT id, schema_id, jira_type_id, type_name, display_name, enabled, description
|
||||
FROM object_types
|
||||
WHERE LOWER(display_name) LIKE LOWER(?)`
|
||||
: `SELECT id, schema_id, jira_type_id, type_name, display_name, enabled, description
|
||||
FROM object_types
|
||||
WHERE LOWER(display_name) LIKE LOWER(?)`,
|
||||
[`%${typeName}%`]
|
||||
);
|
||||
|
||||
// Get schema info for found types
|
||||
const schemaIds = [...new Set([...byTypeName.map(t => t.schema_id), ...byDisplayName.map(t => t.schema_id)])];
|
||||
const schemas = schemaIds.length > 0
|
||||
? await db.query<{ id: number; jira_schema_id: string; name: string }>(
|
||||
`SELECT id, jira_schema_id, name FROM schemas WHERE id IN (${schemaIds.map(() => '?').join(',')})`,
|
||||
schemaIds
|
||||
)
|
||||
: [];
|
||||
|
||||
const schemaMap = new Map(schemas.map(s => [s.id, s]));
|
||||
|
||||
// Check enabled types via service
|
||||
let enabledTypesFromService: string[] = [];
|
||||
try {
|
||||
const enabledTypes = await services.schemaSyncService.getEnabledObjectTypes();
|
||||
enabledTypesFromService = enabledTypes.map(t => t.typeName);
|
||||
} catch (error) {
|
||||
logger.error('DebugController: Failed to get enabled types from service', error);
|
||||
}
|
||||
|
||||
// Check if type is in enabled list from service
|
||||
const isInEnabledList = enabledTypesFromService.includes(typeName);
|
||||
|
||||
res.json({
|
||||
requestedType: typeName,
|
||||
foundByTypeName: byTypeName.map(t => ({
|
||||
id: t.id,
|
||||
schemaId: t.schema_id,
|
||||
jiraSchemaId: schemaMap.get(t.schema_id)?.jira_schema_id,
|
||||
schemaName: schemaMap.get(t.schema_id)?.name,
|
||||
jiraTypeId: t.jira_type_id,
|
||||
typeName: t.type_name,
|
||||
displayName: t.display_name,
|
||||
enabled: t.enabled,
|
||||
enabledValue: isPostgres ? (t.enabled === true) : (t.enabled === 1),
|
||||
hasTypeName: !!(t.type_name && t.type_name.trim() !== ''),
|
||||
description: t.description,
|
||||
})),
|
||||
foundByDisplayName: byDisplayName.filter(t => !byTypeName.some(t2 => t2.id === t.id)).map(t => ({
|
||||
id: t.id,
|
||||
schemaId: t.schema_id,
|
||||
jiraSchemaId: schemaMap.get(t.schema_id)?.jira_schema_id,
|
||||
schemaName: schemaMap.get(t.schema_id)?.name,
|
||||
jiraTypeId: t.jira_type_id,
|
||||
typeName: t.type_name,
|
||||
displayName: t.display_name,
|
||||
enabled: t.enabled,
|
||||
enabledValue: isPostgres ? (t.enabled === true) : (t.enabled === 1),
|
||||
hasTypeName: !!(t.type_name && t.type_name.trim() !== ''),
|
||||
description: t.description,
|
||||
})),
|
||||
diagnosis: {
|
||||
found: byTypeName.length > 0 || byDisplayName.length > 0,
|
||||
foundExact: byTypeName.length > 0,
|
||||
foundByDisplay: byDisplayName.length > 0,
|
||||
isEnabled: byTypeName.length > 0
|
||||
? (isPostgres ? (byTypeName[0].enabled === true) : (byTypeName[0].enabled === 1))
|
||||
: byDisplayName.length > 0
|
||||
? (isPostgres ? (byDisplayName[0].enabled === true) : (byDisplayName[0].enabled === 1))
|
||||
: false,
|
||||
hasTypeName: byTypeName.length > 0
|
||||
? !!(byTypeName[0].type_name && byTypeName[0].type_name.trim() !== '')
|
||||
: byDisplayName.length > 0
|
||||
? !!(byDisplayName[0].type_name && byDisplayName[0].type_name.trim() !== '')
|
||||
: false,
|
||||
isInEnabledList,
|
||||
issue: !isInEnabledList && (byTypeName.length > 0 || byDisplayName.length > 0)
|
||||
? (byTypeName.length > 0 && !(byTypeName[0].type_name && byTypeName[0].type_name.trim() !== '')
|
||||
? 'Type is enabled in database but has missing type_name (will be filtered out)'
|
||||
: byTypeName.length > 0 && !(isPostgres ? (byTypeName[0].enabled === true) : (byTypeName[0].enabled === 1))
|
||||
? 'Type exists but is not enabled in database'
|
||||
: 'Type exists but not found in enabled list (may have missing type_name)')
|
||||
: !isInEnabledList && byTypeName.length === 0 && byDisplayName.length === 0
|
||||
? 'Type not found in database'
|
||||
: 'No issues detected',
|
||||
},
|
||||
enabledTypesCount: enabledTypesFromService.length,
|
||||
enabledTypesList: enabledTypesFromService,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`DebugController: Failed to diagnose object type ${req.params.typeName}`, error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix object types with missing type_name
|
||||
* POST /api/v2/debug/fix-missing-type-names
|
||||
* This will try to fix object types that have NULL type_name by looking up by display_name
|
||||
*/
|
||||
async fixMissingTypeNames(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const services = getServices();
|
||||
const db = services.schemaRepo.db;
|
||||
|
||||
// Find all object types with NULL or empty type_name
|
||||
// Also check for enabled ones specifically
|
||||
const isPostgres = db.isPostgres === true;
|
||||
const enabledCondition = isPostgres ? 'enabled IS true' : 'enabled = 1';
|
||||
|
||||
const brokenTypes = await db.query<{
|
||||
id: number;
|
||||
jira_type_id: number;
|
||||
display_name: string;
|
||||
type_name: string | null;
|
||||
enabled: boolean | number;
|
||||
}>(
|
||||
`SELECT id, jira_type_id, display_name, type_name, enabled
|
||||
FROM object_types
|
||||
WHERE (type_name IS NULL OR type_name = '')
|
||||
ORDER BY enabled DESC, display_name`
|
||||
);
|
||||
|
||||
// Also check enabled types specifically
|
||||
const enabledWithNullTypeName = await db.query<{
|
||||
id: number;
|
||||
jira_type_id: number;
|
||||
display_name: string;
|
||||
type_name: string | null;
|
||||
enabled: boolean | number;
|
||||
}>(
|
||||
`SELECT id, jira_type_id, display_name, type_name, enabled
|
||||
FROM object_types
|
||||
WHERE (type_name IS NULL OR type_name = '') AND ${enabledCondition}`
|
||||
);
|
||||
|
||||
if (enabledWithNullTypeName.length > 0) {
|
||||
logger.warn(`DebugController: Found ${enabledWithNullTypeName.length} ENABLED object types with missing type_name: ${enabledWithNullTypeName.map(t => t.display_name).join(', ')}`);
|
||||
}
|
||||
|
||||
logger.info(`DebugController: Found ${brokenTypes.length} object types with missing type_name`);
|
||||
|
||||
const fixes: Array<{ id: number; displayName: string; fixedTypeName: string }> = [];
|
||||
const errors: Array<{ id: number; error: string }> = [];
|
||||
|
||||
for (const broken of brokenTypes) {
|
||||
try {
|
||||
// Generate type_name from display_name using toPascalCase
|
||||
const { toPascalCase } = await import('../../services/schemaUtils.js');
|
||||
const fixedTypeName = toPascalCase(broken.display_name);
|
||||
|
||||
if (!fixedTypeName || fixedTypeName.trim() === '') {
|
||||
errors.push({
|
||||
id: broken.id,
|
||||
error: `Could not generate type_name from display_name: "${broken.display_name}"`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update the record
|
||||
await db.execute(
|
||||
`UPDATE object_types SET type_name = ?, updated_at = ? WHERE id = ?`,
|
||||
[fixedTypeName, new Date().toISOString(), broken.id]
|
||||
);
|
||||
|
||||
fixes.push({
|
||||
id: broken.id,
|
||||
displayName: broken.display_name,
|
||||
fixedTypeName,
|
||||
});
|
||||
|
||||
logger.info(`DebugController: Fixed object type id=${broken.id}, display_name="${broken.display_name}" -> type_name="${fixedTypeName}"`);
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
id: broken.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch enabled types to verify fix (reuse services from line 294)
|
||||
const enabledTypesAfterFix = await services.schemaSyncService.getEnabledObjectTypes();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
fixed: fixes.length,
|
||||
errors: errors.length,
|
||||
fixes,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
enabledTypesAfterFix: enabledTypesAfterFix.map(t => t.typeName),
|
||||
note: enabledWithNullTypeName.length > 0
|
||||
? `Fixed ${enabledWithNullTypeName.length} enabled types that were missing type_name. They should now appear in enabled types list.`
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('DebugController: Failed to fix missing type names', error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
54
backend/src/api/controllers/HealthController.ts
Normal file
54
backend/src/api/controllers/HealthController.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* HealthController - API health check endpoint
|
||||
*
|
||||
* Public endpoint (no auth required) to check if V2 API is working.
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { logger } from '../../services/logger.js';
|
||||
import { getServices } from '../../services/ServiceFactory.js';
|
||||
|
||||
export class HealthController {
|
||||
/**
|
||||
* Health check endpoint
|
||||
* GET /api/v2/health
|
||||
*/
|
||||
async health(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const services = getServices();
|
||||
|
||||
// Check if services are initialized
|
||||
const isInitialized = !!services.queryService;
|
||||
|
||||
// Check database connection (simple query)
|
||||
let dbConnected = false;
|
||||
try {
|
||||
await services.schemaRepo.getAllSchemas();
|
||||
dbConnected = true;
|
||||
} catch (error) {
|
||||
logger.warn('V2 Health: Database connection check failed', error);
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
apiVersion: 'v2',
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
initialized: isInitialized,
|
||||
database: dbConnected ? 'connected' : 'disconnected',
|
||||
},
|
||||
featureFlag: {
|
||||
useV2Api: process.env.USE_V2_API === 'true',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('V2 Health: Health check failed', error);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
apiVersion: 'v2',
|
||||
timestamp: new Date().toISOString(),
|
||||
error: 'Health check failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
176
backend/src/api/controllers/ObjectsController.ts
Normal file
176
backend/src/api/controllers/ObjectsController.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* ObjectsController - API handlers for object operations
|
||||
*
|
||||
* NO SQL, NO parsing - delegates to services.
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { logger } from '../../services/logger.js';
|
||||
import { getServices } from '../../services/ServiceFactory.js';
|
||||
import type { CMDBObject, CMDBObjectTypeName } from '../../generated/jira-types.js';
|
||||
import { getParamString, getQueryString, getQueryNumber } from '../../utils/queryHelpers.js';
|
||||
|
||||
export class ObjectsController {
|
||||
/**
|
||||
* Get a single object by ID or objectKey
|
||||
* GET /api/v2/objects/:type/:id?refresh=true
|
||||
* Supports both object ID and objectKey (checks objectKey if ID lookup fails)
|
||||
*/
|
||||
async getObject(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const type = getParamString(req, 'type');
|
||||
const idOrKey = getParamString(req, 'id');
|
||||
const forceRefresh = getQueryString(req, 'refresh') === 'true';
|
||||
|
||||
const services = getServices();
|
||||
|
||||
// Try to find object ID if idOrKey might be an objectKey
|
||||
let objectId = idOrKey;
|
||||
let objRecord = await services.cacheRepo.getObject(idOrKey);
|
||||
if (!objRecord) {
|
||||
// Try as objectKey
|
||||
objRecord = await services.cacheRepo.getObjectByKey(idOrKey);
|
||||
if (objRecord) {
|
||||
objectId = objRecord.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Force refresh if requested
|
||||
if (forceRefresh && objectId) {
|
||||
const enabledTypes = await services.schemaRepo.getEnabledObjectTypes();
|
||||
const enabledTypeSet = new Set(enabledTypes.map(t => t.typeName));
|
||||
|
||||
const refreshResult = await services.refreshService.refreshObject(objectId, enabledTypeSet);
|
||||
if (!refreshResult.success) {
|
||||
res.status(500).json({ error: refreshResult.error || 'Failed to refresh object' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get from cache
|
||||
if (!objectId) {
|
||||
res.status(404).json({ error: 'Object not found (by ID or key)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const object = await services.queryService.getObject<CMDBObject>(type as CMDBObjectTypeName, objectId);
|
||||
|
||||
if (!object) {
|
||||
res.status(404).json({ error: 'Object not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(object);
|
||||
} catch (error) {
|
||||
logger.error('ObjectsController: Failed to get object', error);
|
||||
res.status(500).json({ error: 'Failed to get object' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all objects of a type
|
||||
* GET /api/v2/objects/:type?limit=100&offset=0&search=term
|
||||
*/
|
||||
async getObjects(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const type = getParamString(req, 'type');
|
||||
const limit = getQueryNumber(req, 'limit', 1000);
|
||||
const offset = getQueryNumber(req, 'offset', 0);
|
||||
const search = getQueryString(req, 'search');
|
||||
|
||||
const services = getServices();
|
||||
|
||||
logger.info(`ObjectsController.getObjects: Querying for type="${type}" with limit=${limit}, offset=${offset}, search=${search || 'none'}`);
|
||||
|
||||
let objects: CMDBObject[];
|
||||
if (search) {
|
||||
objects = await services.queryService.searchByLabel<CMDBObject>(
|
||||
type as CMDBObjectTypeName,
|
||||
search,
|
||||
{ limit, offset }
|
||||
);
|
||||
} else {
|
||||
objects = await services.queryService.getObjects<CMDBObject>(
|
||||
type as CMDBObjectTypeName,
|
||||
{ limit, offset }
|
||||
);
|
||||
}
|
||||
|
||||
const totalCount = await services.queryService.countObjects(type as CMDBObjectTypeName);
|
||||
|
||||
logger.info(`ObjectsController.getObjects: Found ${objects.length} objects of type "${type}" (total count: ${totalCount})`);
|
||||
|
||||
// If no objects found, provide diagnostic information
|
||||
if (objects.length === 0) {
|
||||
// Check what object types actually exist in the database
|
||||
const db = services.cacheRepo.db;
|
||||
try {
|
||||
const availableTypes = await db.query<{ object_type_name: string; count: number }>(
|
||||
`SELECT object_type_name, COUNT(*) as count
|
||||
FROM objects
|
||||
GROUP BY object_type_name
|
||||
ORDER BY count DESC
|
||||
LIMIT 10`
|
||||
);
|
||||
|
||||
if (availableTypes.length > 0) {
|
||||
logger.warn(`ObjectsController.getObjects: No objects found for type "${type}". Available types in database:`, {
|
||||
requestedType: type,
|
||||
availableTypes: availableTypes.map(t => ({ typeName: t.object_type_name, count: t.count })),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('ObjectsController.getObjects: Failed to query available types', error);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
objectType: type,
|
||||
objects,
|
||||
count: objects.length,
|
||||
totalCount,
|
||||
offset,
|
||||
limit,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('ObjectsController: Failed to get objects', error);
|
||||
res.status(500).json({ error: 'Failed to get objects' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an object
|
||||
* PUT /api/v2/objects/:type/:id
|
||||
*/
|
||||
async updateObject(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const type = getParamString(req, 'type');
|
||||
const id = getParamString(req, 'id');
|
||||
const updates = req.body as Record<string, unknown>;
|
||||
|
||||
const services = getServices();
|
||||
|
||||
const result = await services.writeThroughService.updateObject(
|
||||
type as CMDBObjectTypeName,
|
||||
id,
|
||||
updates
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(400).json({ error: result.error || 'Failed to update object' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch updated object
|
||||
const updated = await services.queryService.getObject<CMDBObject>(
|
||||
type as CMDBObjectTypeName,
|
||||
id
|
||||
);
|
||||
|
||||
res.json(updated || { success: true });
|
||||
} catch (error) {
|
||||
logger.error('ObjectsController: Failed to update object', error);
|
||||
res.status(500).json({ error: 'Failed to update object' });
|
||||
}
|
||||
}
|
||||
}
|
||||
277
backend/src/api/controllers/SyncController.ts
Normal file
277
backend/src/api/controllers/SyncController.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* SyncController - API handlers for sync operations
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { logger } from '../../services/logger.js';
|
||||
import { getServices } from '../../services/ServiceFactory.js';
|
||||
|
||||
export class SyncController {
|
||||
/**
|
||||
* Sync all schemas
|
||||
* POST /api/v2/sync/schemas
|
||||
*/
|
||||
async syncSchemas(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const services = getServices();
|
||||
const result = await services.schemaSyncService.syncAllSchemas();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('SyncController: Failed to sync schemas', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all enabled object types
|
||||
* POST /api/v2/sync/objects
|
||||
*/
|
||||
async syncAllObjects(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const services = getServices();
|
||||
|
||||
// Get enabled types
|
||||
const enabledTypes = await services.schemaSyncService.getEnabledObjectTypes();
|
||||
|
||||
if (enabledTypes.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'No object types enabled for syncing. Please configure object types in Schema Configuration.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
let totalObjectsProcessed = 0;
|
||||
let totalObjectsCached = 0;
|
||||
let totalRelations = 0;
|
||||
|
||||
// Sync each enabled type
|
||||
for (const type of enabledTypes) {
|
||||
const result = await services.objectSyncService.syncObjectType(
|
||||
type.schemaId,
|
||||
type.objectTypeId,
|
||||
type.typeName,
|
||||
type.displayName
|
||||
);
|
||||
|
||||
results.push({
|
||||
typeName: type.typeName,
|
||||
displayName: type.displayName,
|
||||
...result,
|
||||
});
|
||||
|
||||
totalObjectsProcessed += result.objectsProcessed;
|
||||
totalObjectsCached += result.objectsCached;
|
||||
totalRelations += result.relationsExtracted;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats: results,
|
||||
totalObjectsProcessed,
|
||||
totalObjectsCached,
|
||||
totalRelations,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('SyncController: Failed to sync objects', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a specific object type
|
||||
* POST /api/v2/sync/objects/:typeName
|
||||
*/
|
||||
async syncObjectType(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const typeName = req.params.typeName;
|
||||
const services = getServices();
|
||||
|
||||
// Get enabled types
|
||||
let enabledTypes = await services.schemaSyncService.getEnabledObjectTypes();
|
||||
|
||||
// Filter out any entries with missing typeName
|
||||
enabledTypes = enabledTypes.filter(t => t && t.typeName);
|
||||
|
||||
// Debug logging - also check database directly
|
||||
logger.info(`SyncController: Looking for type "${typeName}" in ${enabledTypes.length} enabled types`);
|
||||
logger.debug(`SyncController: Enabled types: ${JSON.stringify(enabledTypes.map(t => ({ typeName: t?.typeName, displayName: t?.displayName })))}`);
|
||||
|
||||
// Additional debug: Check database directly for enabled types (including those with missing type_name)
|
||||
const db = services.schemaRepo.db;
|
||||
const isPostgres = db.isPostgres === true;
|
||||
const enabledCondition = isPostgres ? 'enabled IS true' : 'enabled = 1';
|
||||
const dbCheck = await db.query<{ type_name: string | null; display_name: string; enabled: boolean | number; id: number; jira_type_id: number }>(
|
||||
`SELECT id, jira_type_id, type_name, display_name, enabled FROM object_types WHERE ${enabledCondition}`
|
||||
);
|
||||
logger.info(`SyncController: Found ${dbCheck.length} enabled types in database (raw check)`);
|
||||
logger.debug(`SyncController: Database enabled types (raw): ${JSON.stringify(dbCheck.map(t => ({ id: t.id, displayName: t.display_name, typeName: t.type_name, hasTypeName: !!(t.type_name && t.type_name.trim() !== '') })))}`);
|
||||
|
||||
// Check if AzureSubscription or similar is enabled but missing type_name
|
||||
const matchingByDisplayName = dbCheck.filter(t =>
|
||||
t.display_name.toLowerCase().includes(typeName.toLowerCase()) ||
|
||||
typeName.toLowerCase().includes(t.display_name.toLowerCase())
|
||||
);
|
||||
if (matchingByDisplayName.length > 0) {
|
||||
logger.warn(`SyncController: Found enabled type(s) matching "${typeName}" by display_name but not in enabled list:`, {
|
||||
matches: matchingByDisplayName.map(t => ({
|
||||
id: t.id,
|
||||
displayName: t.display_name,
|
||||
typeName: t.type_name,
|
||||
hasTypeName: !!(t.type_name && t.type_name.trim() !== ''),
|
||||
enabled: t.enabled,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const type = enabledTypes.find(t => t && t.typeName === typeName);
|
||||
|
||||
if (!type) {
|
||||
// Check if type exists but is not enabled or has missing type_name
|
||||
const allType = await services.schemaRepo.getObjectTypeByTypeName(typeName);
|
||||
if (allType) {
|
||||
// Debug: Check the actual enabled value and query
|
||||
const enabledValue = allType.enabled;
|
||||
const enabledType = typeof enabledValue;
|
||||
logger.warn(`SyncController: Type "${typeName}" found but not in enabled list. enabled=${enabledValue} (type: ${enabledType}), enabledTypes.length=${enabledTypes.length}`);
|
||||
logger.debug(`SyncController: Enabled types details: ${JSON.stringify(enabledTypes)}`);
|
||||
|
||||
// Try to find it with different case (handle undefined typeName)
|
||||
const caseInsensitiveMatch = enabledTypes.find(t => t && t.typeName && t.typeName.toLowerCase() === typeName.toLowerCase());
|
||||
if (caseInsensitiveMatch) {
|
||||
logger.warn(`SyncController: Found type with different case: "${caseInsensitiveMatch.typeName}" vs "${typeName}"`);
|
||||
// Use the found type with correct case
|
||||
const result = await services.objectSyncService.syncObjectType(
|
||||
caseInsensitiveMatch.schemaId,
|
||||
caseInsensitiveMatch.objectTypeId,
|
||||
caseInsensitiveMatch.typeName,
|
||||
caseInsensitiveMatch.displayName
|
||||
);
|
||||
res.json({
|
||||
success: true,
|
||||
...result,
|
||||
hasErrors: result.errors.length > 0,
|
||||
note: `Type name case corrected: "${typeName}" -> "${caseInsensitiveMatch.typeName}"`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct SQL query to verify enabled status and type_name
|
||||
const db = services.schemaRepo.db;
|
||||
const isPostgres = db.isPostgres === true;
|
||||
const rawCheck = await db.queryOne<{ enabled: boolean | number; type_name: string | null; display_name: string }>(
|
||||
`SELECT enabled, type_name, display_name FROM object_types WHERE type_name = ?`,
|
||||
[typeName]
|
||||
);
|
||||
|
||||
// Check if type is enabled but missing type_name in enabled list (might be filtered out)
|
||||
const enabledCondition = isPostgres ? 'enabled IS true' : 'enabled = 1';
|
||||
const enabledWithMissingTypeName = await db.query<{ display_name: string; type_name: string | null; enabled: boolean | number }>(
|
||||
`SELECT display_name, type_name, enabled FROM object_types WHERE display_name ILIKE ? AND ${enabledCondition}`,
|
||||
[`%${typeName}%`]
|
||||
);
|
||||
|
||||
// Get list of all enabled type names for better error message
|
||||
const enabledTypeNames = enabledTypes.map(t => t.typeName).filter(Boolean);
|
||||
|
||||
// Check if the issue is that the type is enabled but has a missing type_name
|
||||
if (rawCheck && (rawCheck.enabled === true || rawCheck.enabled === 1)) {
|
||||
if (!rawCheck.type_name || rawCheck.type_name.trim() === '') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Object type "${typeName}" is enabled in the database but has a missing or empty type_name. This prevents it from being synced. Please run schema sync again to fix the type_name, or use the "Fix Missing Type Names" debug tool (Settings → Debug).`,
|
||||
details: {
|
||||
requestedType: typeName,
|
||||
displayName: rawCheck.display_name,
|
||||
enabledInDatabase: rawCheck.enabled,
|
||||
typeNameInDatabase: rawCheck.type_name,
|
||||
enabledTypesCount: enabledTypes.length,
|
||||
enabledTypeNames: enabledTypeNames,
|
||||
hint: 'Run schema sync to ensure all object types have a valid type_name, or use the Debug page to fix missing type names.',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Object type "${typeName}" is not enabled for syncing. Currently enabled types: ${enabledTypeNames.length > 0 ? enabledTypeNames.join(', ') : 'none'}. Please enable "${typeName}" in Schema Configuration settings (Settings → Schema Configuratie).`,
|
||||
details: {
|
||||
requestedType: typeName,
|
||||
enabledInDatabase: rawCheck?.enabled,
|
||||
typeNameInDatabase: rawCheck?.type_name,
|
||||
enabledTypesCount: enabledTypes.length,
|
||||
enabledTypeNames: enabledTypeNames,
|
||||
hint: enabledTypeNames.length === 0
|
||||
? 'No object types are currently enabled. Please enable at least one object type in Schema Configuration.'
|
||||
: `You enabled: ${enabledTypeNames.join(', ')}. Please enable "${typeName}" if you want to sync it.`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Type not found by type_name - check by display_name (case-insensitive)
|
||||
const db = services.schemaRepo.db;
|
||||
const byDisplayName = await db.queryOne<{ enabled: boolean | number; type_name: string | null; display_name: string }>(
|
||||
`SELECT enabled, type_name, display_name FROM object_types WHERE display_name ILIKE ? LIMIT 1`,
|
||||
[`%${typeName}%`]
|
||||
);
|
||||
|
||||
if (byDisplayName && (byDisplayName.enabled === true || byDisplayName.enabled === 1)) {
|
||||
// Type is enabled but type_name might be missing or different
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Found enabled type "${byDisplayName.display_name}" but it has ${byDisplayName.type_name ? `type_name="${byDisplayName.type_name}"` : 'missing type_name'}. ${!byDisplayName.type_name ? 'Please run schema sync to fix the type_name, or use the "Fix Missing Type Names" debug tool.' : `Please use the correct type_name: "${byDisplayName.type_name}"`}`,
|
||||
details: {
|
||||
requestedType: typeName,
|
||||
foundDisplayName: byDisplayName.display_name,
|
||||
foundTypeName: byDisplayName.type_name,
|
||||
enabledInDatabase: byDisplayName.enabled,
|
||||
hint: !byDisplayName.type_name
|
||||
? 'Run schema sync to ensure all object types have a valid type_name.'
|
||||
: `Use type_name "${byDisplayName.type_name}" instead of "${typeName}"`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Object type ${typeName} not found. Available enabled types: ${enabledTypes.map(t => t.typeName).join(', ') || 'none'}. Please run schema sync first.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await services.objectSyncService.syncObjectType(
|
||||
type.schemaId,
|
||||
type.objectTypeId,
|
||||
type.typeName,
|
||||
type.displayName
|
||||
);
|
||||
|
||||
// Return success even if there are errors (errors are in result.errors array)
|
||||
res.json({
|
||||
success: true,
|
||||
...result,
|
||||
hasErrors: result.errors.length > 0,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`SyncController: Failed to sync object type ${req.params.typeName}`, error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
47
backend/src/api/routes/v2.ts
Normal file
47
backend/src/api/routes/v2.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* V2 API Routes - New refactored architecture
|
||||
*
|
||||
* Feature flag: USE_V2_API=true enables these routes
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { ObjectsController } from '../controllers/ObjectsController.js';
|
||||
import { SyncController } from '../controllers/SyncController.js';
|
||||
import { HealthController } from '../controllers/HealthController.js';
|
||||
import { DebugController } from '../controllers/DebugController.js';
|
||||
import { requireAuth, requirePermission } from '../../middleware/authorization.js';
|
||||
|
||||
const router = Router();
|
||||
const objectsController = new ObjectsController();
|
||||
const syncController = new SyncController();
|
||||
const healthController = new HealthController();
|
||||
const debugController = new DebugController();
|
||||
|
||||
// Health check - public endpoint (no auth required)
|
||||
router.get('/health', (req, res) => healthController.health(req, res));
|
||||
|
||||
// All other routes require authentication
|
||||
router.use(requireAuth);
|
||||
|
||||
// Object routes
|
||||
router.get('/objects/:type', requirePermission('search'), (req, res) => objectsController.getObjects(req, res));
|
||||
router.get('/objects/:type/:id', requirePermission('search'), (req, res) => objectsController.getObject(req, res));
|
||||
router.put('/objects/:type/:id', requirePermission('write'), (req, res) => objectsController.updateObject(req, res));
|
||||
|
||||
// Sync routes
|
||||
router.post('/sync/schemas', requirePermission('admin'), (req, res) => syncController.syncSchemas(req, res));
|
||||
router.post('/sync/objects', requirePermission('admin'), (req, res) => syncController.syncAllObjects(req, res));
|
||||
router.post('/sync/objects/:typeName', requirePermission('admin'), (req, res) => syncController.syncObjectType(req, res));
|
||||
|
||||
// Debug routes (admin only)
|
||||
// IMPORTANT: More specific routes must come BEFORE parameterized routes
|
||||
router.post('/debug/query', requirePermission('admin'), (req, res) => debugController.executeQuery(req, res));
|
||||
router.get('/debug/objects', requirePermission('admin'), (req, res) => debugController.getObjectInfo(req, res));
|
||||
router.get('/debug/relations', requirePermission('admin'), (req, res) => debugController.getRelationInfo(req, res));
|
||||
router.get('/debug/all-object-types', requirePermission('admin'), (req, res) => debugController.getAllObjectTypes(req, res));
|
||||
router.post('/debug/fix-missing-type-names', requirePermission('admin'), (req, res) => debugController.fixMissingTypeNames(req, res));
|
||||
// Specific routes before parameterized routes
|
||||
router.get('/debug/object-types/diagnose/:typeName', requirePermission('admin'), (req, res) => debugController.diagnoseObjectType(req, res));
|
||||
router.get('/debug/object-types/:typeName/stats', requirePermission('admin'), (req, res) => debugController.getObjectTypeStats(req, res));
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user