Add authentication, user management, and database migration features
- Implement OAuth 2.0 and PAT authentication methods - Add user management, roles, and profile functionality - Add database migrations and admin user scripts - Update services for authentication and user settings - Add protected routes and permission hooks - Update documentation for authentication and database access
This commit is contained in:
@@ -30,12 +30,12 @@ interface Config {
|
||||
jiraHost: string;
|
||||
jiraSchemaId: string;
|
||||
|
||||
// Jira Service Account Token (for read operations: sync, fetching data)
|
||||
jiraServiceAccountToken: string;
|
||||
|
||||
// Jira Authentication Method ('pat' or 'oauth')
|
||||
jiraAuthMethod: JiraAuthMethod;
|
||||
|
||||
// Jira Personal Access Token (used when jiraAuthMethod = 'pat')
|
||||
jiraPat: string;
|
||||
|
||||
// Jira OAuth 2.0 Configuration (used when jiraAuthMethod = 'oauth')
|
||||
jiraOAuthClientId: string;
|
||||
jiraOAuthClientSecret: string;
|
||||
@@ -45,14 +45,9 @@ interface Config {
|
||||
// Session Configuration
|
||||
sessionSecret: string;
|
||||
|
||||
// AI API Keys
|
||||
anthropicApiKey: string;
|
||||
openaiApiKey: string;
|
||||
defaultAIProvider: 'claude' | 'openai';
|
||||
|
||||
// Web Search API (Tavily)
|
||||
tavilyApiKey: string;
|
||||
enableWebSearch: boolean;
|
||||
// AI Configuration
|
||||
// Note: API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, TAVILY_API_KEY), default AI provider,
|
||||
// and web search are now configured per-user in their profile settings, not in environment variables
|
||||
|
||||
// Application
|
||||
port: number;
|
||||
@@ -60,6 +55,9 @@ interface Config {
|
||||
isDevelopment: boolean;
|
||||
isProduction: boolean;
|
||||
frontendUrl: string;
|
||||
appName: string;
|
||||
appTagline: string;
|
||||
appCopyright: string;
|
||||
|
||||
// API Configuration
|
||||
jiraApiBatchSize: number;
|
||||
@@ -69,9 +67,9 @@ function getOptionalEnvVar(name: string, defaultValue: string = ''): string {
|
||||
return process.env[name] || defaultValue;
|
||||
}
|
||||
|
||||
// Helper to determine auth method with backward compatibility
|
||||
// Helper to determine auth method
|
||||
function getJiraAuthMethod(): JiraAuthMethod {
|
||||
// Check new JIRA_AUTH_METHOD first
|
||||
// Check JIRA_AUTH_METHOD first
|
||||
const authMethod = getOptionalEnvVar('JIRA_AUTH_METHOD', '').toLowerCase();
|
||||
if (authMethod === 'oauth') return 'oauth';
|
||||
if (authMethod === 'pat') return 'pat';
|
||||
@@ -80,14 +78,12 @@ function getJiraAuthMethod(): JiraAuthMethod {
|
||||
const oauthEnabled = getOptionalEnvVar('JIRA_OAUTH_ENABLED', 'false').toLowerCase() === 'true';
|
||||
if (oauthEnabled) return 'oauth';
|
||||
|
||||
// Default to 'pat' if JIRA_PAT is set, otherwise 'oauth' if OAuth credentials exist
|
||||
const hasPat = !!getOptionalEnvVar('JIRA_PAT');
|
||||
// Default to 'oauth' if OAuth credentials exist, otherwise 'pat'
|
||||
const hasOAuthCredentials = !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID') && !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET');
|
||||
|
||||
if (hasPat) return 'pat';
|
||||
if (hasOAuthCredentials) return 'oauth';
|
||||
|
||||
// Default to 'pat' (will show warning during validation)
|
||||
// Default to 'pat' (users configure PAT in their profile)
|
||||
return 'pat';
|
||||
}
|
||||
|
||||
@@ -96,12 +92,12 @@ export const config: Config = {
|
||||
jiraHost: getOptionalEnvVar('JIRA_HOST', 'https://jira.zuyderland.nl'),
|
||||
jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'),
|
||||
|
||||
// Jira Service Account Token (for read operations: sync, fetching data)
|
||||
jiraServiceAccountToken: getOptionalEnvVar('JIRA_SERVICE_ACCOUNT_TOKEN'),
|
||||
|
||||
// Jira Authentication Method
|
||||
jiraAuthMethod: getJiraAuthMethod(),
|
||||
|
||||
// Jira Personal Access Token (for PAT authentication)
|
||||
jiraPat: getOptionalEnvVar('JIRA_PAT'),
|
||||
|
||||
// Jira OAuth 2.0 Configuration (for OAuth authentication)
|
||||
jiraOAuthClientId: getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID'),
|
||||
jiraOAuthClientSecret: getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'),
|
||||
@@ -111,21 +107,15 @@ export const config: Config = {
|
||||
// Session Configuration
|
||||
sessionSecret: getOptionalEnvVar('SESSION_SECRET', 'change-this-secret-in-production'),
|
||||
|
||||
// AI API Keys
|
||||
anthropicApiKey: getOptionalEnvVar('ANTHROPIC_API_KEY'),
|
||||
openaiApiKey: getOptionalEnvVar('OPENAI_API_KEY'),
|
||||
defaultAIProvider: (getOptionalEnvVar('DEFAULT_AI_PROVIDER', 'claude') as 'claude' | 'openai'),
|
||||
|
||||
// Web Search API (Tavily)
|
||||
tavilyApiKey: getOptionalEnvVar('TAVILY_API_KEY'),
|
||||
enableWebSearch: getOptionalEnvVar('ENABLE_WEB_SEARCH', 'false').toLowerCase() === 'true',
|
||||
|
||||
// Application
|
||||
port: parseInt(getOptionalEnvVar('PORT', '3001'), 10),
|
||||
nodeEnv: getOptionalEnvVar('NODE_ENV', 'development'),
|
||||
isDevelopment: getOptionalEnvVar('NODE_ENV', 'development') === 'development',
|
||||
isProduction: getOptionalEnvVar('NODE_ENV', 'development') === 'production',
|
||||
frontendUrl: getOptionalEnvVar('FRONTEND_URL', 'http://localhost:5173'),
|
||||
appName: getOptionalEnvVar('APP_NAME', 'CMDB Insight'),
|
||||
appTagline: getOptionalEnvVar('APP_TAGLINE', 'Management console for Jira Assets'),
|
||||
appCopyright: getOptionalEnvVar('APP_COPYRIGHT', '© {year} Zuyderland Medisch Centrum'),
|
||||
|
||||
// API Configuration
|
||||
jiraApiBatchSize: parseInt(getOptionalEnvVar('JIRA_API_BATCH_SIZE', '15'), 10),
|
||||
@@ -139,9 +129,8 @@ export function validateConfig(): void {
|
||||
console.log(`Jira Authentication Method: ${config.jiraAuthMethod.toUpperCase()}`);
|
||||
|
||||
if (config.jiraAuthMethod === 'pat') {
|
||||
if (!config.jiraPat) {
|
||||
missingVars.push('JIRA_PAT (required for PAT authentication)');
|
||||
}
|
||||
// JIRA_PAT is configured in user profiles, not in ENV
|
||||
warnings.push('JIRA_AUTH_METHOD=pat - users must configure PAT in their profile settings');
|
||||
} else if (config.jiraAuthMethod === 'oauth') {
|
||||
if (!config.jiraOAuthClientId) {
|
||||
missingVars.push('JIRA_OAUTH_CLIENT_ID (required for OAuth authentication)');
|
||||
@@ -156,7 +145,14 @@ export function validateConfig(): void {
|
||||
|
||||
// General required config
|
||||
if (!config.jiraSchemaId) missingVars.push('JIRA_SCHEMA_ID');
|
||||
if (!config.anthropicApiKey) warnings.push('ANTHROPIC_API_KEY not set - AI classification disabled');
|
||||
|
||||
// Service account token warning (not required, but recommended for sync operations)
|
||||
if (!config.jiraServiceAccountToken) {
|
||||
warnings.push('JIRA_SERVICE_ACCOUNT_TOKEN not configured - sync and read operations may not work. Users can still use their personal PAT for reads as fallback.');
|
||||
}
|
||||
|
||||
// AI API keys are configured in user profiles, not in ENV
|
||||
warnings.push('AI API keys must be configured in user profile settings');
|
||||
|
||||
if (warnings.length > 0) {
|
||||
warnings.forEach(w => console.warn(`Warning: ${w}`));
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface ApplicationComponent extends BaseCMDBObject {
|
||||
updated: string | null;
|
||||
description: string | null; // * Application description
|
||||
status: string | null; // Application Lifecycle Management
|
||||
confluenceSpace: number | null;
|
||||
confluenceSpace: string | number | null; // Can be URL string (from Confluence link) or number (legacy)
|
||||
zenyaID: number | null;
|
||||
zenyaURL: string | null;
|
||||
customDevelopment: boolean | null; // Is er sprake van eigen programmatuur?
|
||||
|
||||
@@ -14,10 +14,15 @@ import referenceDataRouter from './routes/referenceData.js';
|
||||
import dashboardRouter from './routes/dashboard.js';
|
||||
import configurationRouter from './routes/configuration.js';
|
||||
import authRouter, { authMiddleware } from './routes/auth.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
import rolesRouter from './routes/roles.js';
|
||||
import userSettingsRouter from './routes/userSettings.js';
|
||||
import profileRouter from './routes/profile.js';
|
||||
import searchRouter from './routes/search.js';
|
||||
import cacheRouter from './routes/cache.js';
|
||||
import objectsRouter from './routes/objects.js';
|
||||
import schemaRouter from './routes/schema.js';
|
||||
import { runMigrations } from './services/database/migrations.js';
|
||||
|
||||
// Validate configuration
|
||||
validateConfig();
|
||||
@@ -55,13 +60,49 @@ app.use((req, res, next) => {
|
||||
// Auth middleware - extract session info for all requests
|
||||
app.use(authMiddleware);
|
||||
|
||||
// Set user token on CMDBService for each request (for user-specific OAuth)
|
||||
app.use((req, res, next) => {
|
||||
// Set user's OAuth token if available
|
||||
// Set user token and settings on services for each request
|
||||
app.use(async (req, res, next) => {
|
||||
// Set user's OAuth token if available (for OAuth sessions)
|
||||
if (req.accessToken) {
|
||||
cmdbService.setUserToken(req.accessToken);
|
||||
}
|
||||
|
||||
// Set user's Jira PAT and AI keys if user is authenticated and has local account
|
||||
if (req.user && 'id' in req.user) {
|
||||
try {
|
||||
const { userSettingsService } = await import('./services/userSettingsService.js');
|
||||
const settings = await userSettingsService.getUserSettings(req.user.id);
|
||||
|
||||
if (settings?.jira_pat) {
|
||||
// Use user's Jira PAT from profile settings (preferred for writes)
|
||||
cmdbService.setUserToken(settings.jira_pat);
|
||||
} else if (config.jiraServiceAccountToken) {
|
||||
// Fallback to service account token if user doesn't have PAT configured
|
||||
// This allows writes to work when JIRA_SERVICE_ACCOUNT_TOKEN is set in .env
|
||||
cmdbService.setUserToken(config.jiraServiceAccountToken);
|
||||
logger.debug('Using service account token as fallback (user PAT not configured)');
|
||||
} else {
|
||||
// No token available - clear token
|
||||
cmdbService.setUserToken(null);
|
||||
}
|
||||
|
||||
// Store user settings in request for services to access
|
||||
(req as any).userSettings = settings;
|
||||
} catch (error) {
|
||||
// If user settings can't be loaded, try service account token as fallback
|
||||
logger.debug('Failed to load user settings:', error);
|
||||
if (config.jiraServiceAccountToken) {
|
||||
cmdbService.setUserToken(config.jiraServiceAccountToken);
|
||||
logger.debug('Using service account token as fallback (user settings load failed)');
|
||||
} else {
|
||||
cmdbService.setUserToken(null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No user authenticated - clear token
|
||||
cmdbService.setUserToken(null);
|
||||
}
|
||||
|
||||
// Clear token after response is sent
|
||||
res.on('finish', () => {
|
||||
cmdbService.clearUserToken();
|
||||
@@ -80,7 +121,7 @@ app.get('/health', async (req, res) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
dataSource: dataService.isUsingJiraAssets() ? 'jira-assets-cached' : 'mock-data',
|
||||
jiraConnected: dataService.isUsingJiraAssets() ? jiraConnected : null,
|
||||
aiConfigured: !!config.anthropicApiKey,
|
||||
aiConfigured: true, // AI is configured per-user in profile settings
|
||||
cache: {
|
||||
isWarm: cacheStatus.isWarm,
|
||||
objectCount: cacheStatus.totalObjects,
|
||||
@@ -98,6 +139,10 @@ app.get('/api/config', (req, res) => {
|
||||
|
||||
// API routes
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/roles', rolesRouter);
|
||||
app.use('/api/user-settings', userSettingsRouter);
|
||||
app.use('/api/profile', profileRouter);
|
||||
app.use('/api/applications', applicationsRouter);
|
||||
app.use('/api/classifications', classificationsRouter);
|
||||
app.use('/api/reference-data', referenceDataRouter);
|
||||
@@ -127,14 +172,24 @@ const PORT = config.port;
|
||||
app.listen(PORT, async () => {
|
||||
logger.info(`Server running on http://localhost:${PORT}`);
|
||||
logger.info(`Environment: ${config.nodeEnv}`);
|
||||
logger.info(`AI Classification: ${config.anthropicApiKey ? 'Configured' : 'Not configured'}`);
|
||||
logger.info(`Jira Assets: ${config.jiraPat ? 'Configured with caching' : 'Using mock data'}`);
|
||||
logger.info(`AI Classification: Configured per-user in profile settings`);
|
||||
logger.info(`Jira Assets: ${config.jiraSchemaId ? 'Schema configured - users configure PAT in profile' : 'Schema not configured'}`);
|
||||
|
||||
// Initialize sync engine if using Jira Assets
|
||||
if (config.jiraPat && config.jiraSchemaId) {
|
||||
// Run database migrations
|
||||
try {
|
||||
await runMigrations();
|
||||
logger.info('Database migrations completed');
|
||||
} catch (error) {
|
||||
logger.error('Failed to run database migrations', error);
|
||||
}
|
||||
|
||||
// Initialize sync engine if Jira schema is configured
|
||||
// Note: Sync engine will only sync when users with configured Jira PATs make requests
|
||||
// This prevents unauthorized Jira API calls
|
||||
if (config.jiraSchemaId) {
|
||||
try {
|
||||
await syncEngine.initialize();
|
||||
logger.info('Sync Engine: Initialized and running');
|
||||
logger.info('Sync Engine: Initialized (sync on-demand per user request)');
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize sync engine', error);
|
||||
}
|
||||
|
||||
115
backend/src/middleware/authorization.ts
Normal file
115
backend/src/middleware/authorization.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Authorization Middleware
|
||||
*
|
||||
* Middleware functions for route protection based on authentication and permissions.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { authService, type SessionUser } from '../services/authService.js';
|
||||
import { roleService } from '../services/roleService.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
|
||||
// Extend Express Request to include user info
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
sessionId?: string;
|
||||
user?: SessionUser;
|
||||
accessToken?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require authentication
|
||||
*/
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// Get session user
|
||||
authService.getSession(sessionId)
|
||||
.then(session => {
|
||||
if (!session) {
|
||||
return res.status(401).json({ error: 'Invalid or expired session' });
|
||||
}
|
||||
|
||||
// Check if it's a local user session
|
||||
if ('id' in session.user) {
|
||||
req.sessionId = sessionId;
|
||||
req.user = session.user as SessionUser;
|
||||
req.accessToken = session.accessToken;
|
||||
next();
|
||||
} else {
|
||||
// OAuth-only session (Jira user without local account)
|
||||
// For now, allow through but user won't have permissions
|
||||
req.sessionId = sessionId;
|
||||
req.accessToken = session.accessToken;
|
||||
next();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
logger.error('Auth middleware error:', error);
|
||||
res.status(500).json({ error: 'Authentication check failed' });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require a specific role
|
||||
*/
|
||||
export function requireRole(roleName: string) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user || !('id' in req.user)) {
|
||||
return res.status(403).json({ error: 'Permission denied' });
|
||||
}
|
||||
|
||||
const hasRole = await roleService.userHasRole(req.user.id, roleName);
|
||||
if (!hasRole) {
|
||||
return res.status(403).json({ error: `Role '${roleName}' required` });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require a specific permission
|
||||
*/
|
||||
export function requirePermission(permissionName: string) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user || !('id' in req.user)) {
|
||||
return res.status(403).json({ error: 'Permission denied' });
|
||||
}
|
||||
|
||||
const hasPermission = await roleService.userHasPermission(req.user.id, permissionName);
|
||||
if (!hasPermission) {
|
||||
return res.status(403).json({ error: `Permission '${permissionName}' required` });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to check permission (optional, doesn't fail if missing)
|
||||
* Sets req.hasPermission flag
|
||||
*/
|
||||
export function checkPermission(permissionName: string) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.user && 'id' in req.user) {
|
||||
const hasPermission = await roleService.userHasPermission(req.user.id, permissionName);
|
||||
(req as any).hasPermission = hasPermission;
|
||||
} else {
|
||||
(req as any).hasPermission = false;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require admin role
|
||||
*/
|
||||
export const requireAdmin = requireRole('administrator');
|
||||
@@ -7,13 +7,17 @@ import { calculateRequiredEffortApplicationManagementWithBreakdown } from '../se
|
||||
import { findBIAMatch, loadBIAData, clearBIACache, calculateSimilarity } from '../services/biaMatchingService.js';
|
||||
import { calculateApplicationCompleteness } from '../services/dataCompletenessConfig.js';
|
||||
import { getQueryString, getParamString } from '../utils/queryHelpers.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
import type { SearchFilters, ReferenceValue, ClassificationResult, ApplicationDetails, ApplicationStatus } from '../types/index.js';
|
||||
import type { Server, Flows, Certificate, Domain, AzureSubscription, CMDBObjectTypeName } from '../generated/jira-types.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Search applications with filters
|
||||
router.post('/search', async (req: Request, res: Response) => {
|
||||
// All routes require authentication
|
||||
router.use(requireAuth);
|
||||
|
||||
// Search applications with filters (requires search permission)
|
||||
router.post('/search', requirePermission('search'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filters, page = 1, pageSize = 25 } = req.body as {
|
||||
filters: SearchFilters;
|
||||
@@ -356,9 +360,22 @@ router.get('/:id', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update application with conflict detection
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
// Update application with conflict detection (requires edit permission)
|
||||
router.put('/:id', requirePermission('edit_applications'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Check if user has Jira PAT configured OR service account token is available (required for write operations)
|
||||
const userSettings = (req as any).userSettings;
|
||||
const { config } = await import('../config/env.js');
|
||||
|
||||
// Allow writes if user has PAT OR service account token is configured
|
||||
if (!userSettings?.jira_pat && !config.jiraServiceAccountToken) {
|
||||
res.status(403).json({
|
||||
error: 'Jira PAT not configured',
|
||||
message: 'A Personal Access Token (PAT) is required to save changes to Jira Assets. Please configure it in your user settings, or configure JIRA_SERVICE_ACCOUNT_TOKEN in .env as a fallback.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const id = getParamString(req, 'id');
|
||||
const { updates, _jiraUpdatedAt } = req.body as {
|
||||
updates?: {
|
||||
@@ -468,9 +485,22 @@ router.put('/:id', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Force update (ignore conflicts)
|
||||
router.put('/:id/force', async (req: Request, res: Response) => {
|
||||
// Force update (ignore conflicts) (requires edit permission)
|
||||
router.put('/:id/force', requirePermission('edit_applications'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Check if user has Jira PAT configured OR service account token is available (required for write operations)
|
||||
const userSettings = (req as any).userSettings;
|
||||
const { config } = await import('../config/env.js');
|
||||
|
||||
// Allow writes if user has PAT OR service account token is configured
|
||||
if (!userSettings?.jira_pat && !config.jiraServiceAccountToken) {
|
||||
res.status(403).json({
|
||||
error: 'Jira PAT not configured',
|
||||
message: 'A Personal Access Token (PAT) is required to save changes to Jira Assets. Please configure it in your user settings, or configure JIRA_SERVICE_ACCOUNT_TOKEN in .env as a fallback.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const id = getParamString(req, 'id');
|
||||
const updates = req.body;
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { authService, JiraUser } from '../services/authService.js';
|
||||
import { authService, type SessionUser, type JiraUser } from '../services/authService.js';
|
||||
import { userService } from '../services/userService.js';
|
||||
import { roleService } from '../services/roleService.js';
|
||||
import { config } from '../config/env.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { getAuthDatabase } from '../services/database/migrations.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -10,55 +13,179 @@ declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
sessionId?: string;
|
||||
user?: JiraUser;
|
||||
user?: SessionUser | JiraUser;
|
||||
accessToken?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get auth configuration
|
||||
router.get('/config', (req: Request, res: Response) => {
|
||||
const authMethod = authService.getAuthMethod();
|
||||
router.get('/config', async (req: Request, res: Response) => {
|
||||
// JIRA_AUTH_METHOD is only for backend Jira API configuration, NOT for application authentication
|
||||
// Application authentication is ALWAYS via local auth or OAuth
|
||||
// Users authenticate to the application, then their PAT/OAuth token is used for Jira API writes
|
||||
// JIRA_SERVICE_ACCOUNT_TOKEN is used for Jira API reads
|
||||
|
||||
// Check if users exist in database (if migrations have run)
|
||||
let hasUsers = false;
|
||||
try {
|
||||
const db = getAuthDatabase();
|
||||
const userCount = await db.queryOne<{ count: number }>(
|
||||
'SELECT COUNT(*) as count FROM users'
|
||||
);
|
||||
hasUsers = (userCount?.count || 0) > 0;
|
||||
await db.close();
|
||||
} catch (error) {
|
||||
// If table doesn't exist yet, hasUsers stays false
|
||||
}
|
||||
|
||||
// Local auth is ALWAYS enabled for application authentication
|
||||
// (unless explicitly disabled via LOCAL_AUTH_ENABLED=false)
|
||||
// This allows users to create accounts and log in
|
||||
const localAuthEnabled = process.env.LOCAL_AUTH_ENABLED !== 'false';
|
||||
|
||||
// OAuth is enabled if configured
|
||||
const oauthEnabled = authService.isOAuthEnabled();
|
||||
|
||||
// Service accounts are NOT used for application authentication
|
||||
// They are only for Jira API read access (JIRA_SERVICE_ACCOUNT_TOKEN in .env)
|
||||
// serviceAccountEnabled should always be false for authentication purposes
|
||||
|
||||
// authMethod is 'local' if local auth is enabled, 'oauth' if only OAuth, or 'none' if both disabled
|
||||
let authMethod: 'local' | 'oauth' | 'none' = 'none';
|
||||
if (localAuthEnabled && oauthEnabled) {
|
||||
authMethod = 'local'; // Default to local, user can choose
|
||||
} else if (localAuthEnabled) {
|
||||
authMethod = 'local';
|
||||
} else if (oauthEnabled) {
|
||||
authMethod = 'oauth';
|
||||
}
|
||||
|
||||
res.json({
|
||||
// Configured authentication method ('pat', 'oauth', or 'none')
|
||||
// Application branding
|
||||
appName: config.appName,
|
||||
appTagline: config.appTagline,
|
||||
appCopyright: config.appCopyright,
|
||||
// Application authentication method (always 'local' or 'oauth', never 'pat')
|
||||
// 'pat' is only for backend Jira API configuration, not user authentication
|
||||
authMethod,
|
||||
// Legacy fields for backward compatibility
|
||||
oauthEnabled: authService.isOAuthEnabled(),
|
||||
serviceAccountEnabled: authService.isUsingServiceAccount(),
|
||||
// Authentication options
|
||||
oauthEnabled,
|
||||
serviceAccountEnabled: false, // Service accounts are NOT for app authentication
|
||||
localAuthEnabled,
|
||||
// Jira host for display purposes
|
||||
jiraHost: config.jiraHost,
|
||||
});
|
||||
});
|
||||
|
||||
// Get current user (check if logged in)
|
||||
router.get('/me', (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||
router.get('/me', async (req: Request, res: Response) => {
|
||||
// The sessionId should already be set by authMiddleware from cookies
|
||||
const sessionId = req.sessionId || req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||
|
||||
logger.debug(`[GET /me] SessionId: ${sessionId ? sessionId.substring(0, 8) + '...' : 'none'}, Cookies: ${JSON.stringify(req.cookies)}`);
|
||||
|
||||
// Service accounts are NOT used for application authentication
|
||||
// They are only used for Jira API access (configured in .env as JIRA_SERVICE_ACCOUNT_TOKEN)
|
||||
// Application authentication requires a real user session (local or OAuth)
|
||||
|
||||
if (!sessionId) {
|
||||
// If OAuth not enabled, allow anonymous access with service account
|
||||
if (authService.isUsingServiceAccount() && !authService.isOAuthEnabled()) {
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
authMethod: 'service-account',
|
||||
user: {
|
||||
accountId: 'service-account',
|
||||
displayName: 'Service Account',
|
||||
},
|
||||
});
|
||||
// No session = not authenticated
|
||||
// Service account mode is NOT a valid authentication method for the application
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await authService.getSession(sessionId);
|
||||
if (!session) {
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
|
||||
// Determine auth method from session
|
||||
let authMethod = 'local';
|
||||
if ('accountId' in session.user) {
|
||||
authMethod = 'oauth';
|
||||
} else if ('id' in session.user) {
|
||||
authMethod = 'local';
|
||||
}
|
||||
|
||||
// For local users, ensure we have all required fields
|
||||
let userData = session.user;
|
||||
if ('id' in session.user) {
|
||||
// Local user - ensure proper format
|
||||
userData = {
|
||||
id: session.user.id,
|
||||
email: session.user.email || session.user.emailAddress,
|
||||
username: session.user.username,
|
||||
displayName: session.user.displayName,
|
||||
emailAddress: session.user.email || session.user.emailAddress,
|
||||
roles: session.user.roles || [],
|
||||
permissions: session.user.permissions || [],
|
||||
};
|
||||
}
|
||||
|
||||
res.json({
|
||||
authenticated: true,
|
||||
authMethod,
|
||||
user: userData,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting session:', error);
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
});
|
||||
|
||||
const user = authService.getUser(sessionId);
|
||||
if (!user) {
|
||||
return res.json({ authenticated: false });
|
||||
// Local login (email/password)
|
||||
router.post('/login', async (req: Request, res: Response) => {
|
||||
if (!authService.isLocalAuthEnabled()) {
|
||||
return res.status(400).json({ error: 'Local authentication is not enabled' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
authenticated: true,
|
||||
authMethod: 'oauth',
|
||||
user,
|
||||
});
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email and password are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const ipAddress = req.ip || req.socket.remoteAddress || undefined;
|
||||
const userAgent = req.get('user-agent') || undefined;
|
||||
|
||||
const { sessionId, user } = await authService.localLogin(email, password, ipAddress, userAgent);
|
||||
|
||||
// Set session cookie
|
||||
// Note: When using Vite proxy, cookies work correctly as the proxy forwards them
|
||||
// In development, use 'lax' for same-site requests (localhost:5173 -> localhost:3001 via proxy)
|
||||
// In production, use 'lax' for security
|
||||
const cookieOptions: any = {
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/', // Make cookie available for all paths
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
};
|
||||
|
||||
// In development, don't set domain (defaults to current host)
|
||||
// This allows the cookie to work with the Vite proxy
|
||||
if (!config.isDevelopment) {
|
||||
// In production, you might want to set domain explicitly if needed
|
||||
// cookieOptions.domain = '.yourdomain.com';
|
||||
}
|
||||
|
||||
res.cookie('sessionId', sessionId, cookieOptions);
|
||||
|
||||
logger.debug(`[Local Login] Session cookie set: sessionId=${sessionId.substring(0, 8)}..., path=/, maxAge=24h, sameSite=lax`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sessionId,
|
||||
user,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Local login error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Login failed';
|
||||
res.status(401).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Initiate OAuth login
|
||||
@@ -102,21 +229,41 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const ipAddress = req.ip || req.socket.remoteAddress || undefined;
|
||||
const userAgent = req.get('user-agent') || undefined;
|
||||
|
||||
// Exchange code for tokens
|
||||
const { sessionId, user } = await authService.exchangeCodeForTokens(
|
||||
String(code),
|
||||
String(state)
|
||||
String(state),
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
|
||||
logger.info(`OAuth login successful for: ${user.displayName}`);
|
||||
|
||||
// Set session cookie
|
||||
res.cookie('sessionId', sessionId, {
|
||||
// Note: When using Vite proxy, cookies work correctly as the proxy forwards them
|
||||
// In development, use 'lax' for same-site requests (localhost:5173 -> localhost:3001 via proxy)
|
||||
// In production, use 'lax' for security
|
||||
const cookieOptions: any = {
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: 'lax',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/', // Make cookie available for all paths
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
});
|
||||
};
|
||||
|
||||
// In development, don't set domain (defaults to current host)
|
||||
// This allows the cookie to work with the Vite proxy
|
||||
if (!config.isDevelopment) {
|
||||
// In production, you might want to set domain explicitly if needed
|
||||
// cookieOptions.domain = '.yourdomain.com';
|
||||
}
|
||||
|
||||
res.cookie('sessionId', sessionId, cookieOptions);
|
||||
|
||||
logger.debug(`[OAuth Login] Session cookie set: sessionId=${sessionId.substring(0, 8)}..., path=/, maxAge=24h, sameSite=lax`);
|
||||
|
||||
// Redirect to frontend with session info
|
||||
res.redirect(`${config.frontendUrl}?login=success`);
|
||||
@@ -128,16 +275,16 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', (req: Request, res: Response) => {
|
||||
router.post('/logout', async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||
|
||||
if (sessionId) {
|
||||
authService.logout(sessionId);
|
||||
await authService.logout(sessionId);
|
||||
}
|
||||
|
||||
// Clear cookies
|
||||
res.clearCookie('sessionId');
|
||||
res.clearCookie('oauth_state');
|
||||
// Clear cookies (must use same path as when setting)
|
||||
res.clearCookie('sessionId', { path: '/' });
|
||||
res.clearCookie('oauth_state', { path: '/' });
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
@@ -159,37 +306,183 @@ router.post('/refresh', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Forgot password
|
||||
router.post('/forgot-password', async (req: Request, res: Response) => {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({ error: 'Email is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.generatePasswordResetToken(email);
|
||||
// Always return success to prevent email enumeration
|
||||
res.json({ success: true, message: 'If an account exists with this email, a password reset link has been sent.' });
|
||||
} catch (error) {
|
||||
logger.error('Forgot password error:', error);
|
||||
// Still return success to prevent email enumeration
|
||||
res.json({ success: true, message: 'If an account exists with this email, a password reset link has been sent.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Reset password
|
||||
router.post('/reset-password', async (req: Request, res: Response) => {
|
||||
const { token, password } = req.body;
|
||||
|
||||
if (!token || !password) {
|
||||
return res.status(400).json({ error: 'Token and password are required' });
|
||||
}
|
||||
|
||||
// Validate password requirements
|
||||
const minLength = parseInt(process.env.PASSWORD_MIN_LENGTH || '8', 10);
|
||||
const requireUppercase = process.env.PASSWORD_REQUIRE_UPPERCASE === 'true';
|
||||
const requireLowercase = process.env.PASSWORD_REQUIRE_LOWERCASE === 'true';
|
||||
const requireNumber = process.env.PASSWORD_REQUIRE_NUMBER === 'true';
|
||||
|
||||
if (password.length < minLength) {
|
||||
return res.status(400).json({ error: `Password must be at least ${minLength} characters long` });
|
||||
}
|
||||
if (requireUppercase && !/[A-Z]/.test(password)) {
|
||||
return res.status(400).json({ error: 'Password must contain at least one uppercase letter' });
|
||||
}
|
||||
if (requireLowercase && !/[a-z]/.test(password)) {
|
||||
return res.status(400).json({ error: 'Password must contain at least one lowercase letter' });
|
||||
}
|
||||
if (requireNumber && !/[0-9]/.test(password)) {
|
||||
return res.status(400).json({ error: 'Password must contain at least one number' });
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await userService.resetPasswordWithToken(token, password);
|
||||
if (success) {
|
||||
res.json({ success: true, message: 'Password reset successfully' });
|
||||
} else {
|
||||
res.status(400).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Reset password error:', error);
|
||||
res.status(500).json({ error: 'Failed to reset password' });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify email
|
||||
router.post('/verify-email', async (req: Request, res: Response) => {
|
||||
const { token } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: 'Token is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await userService.verifyEmail(token);
|
||||
if (success) {
|
||||
res.json({ success: true, message: 'Email verified successfully' });
|
||||
} else {
|
||||
res.status(400).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Verify email error:', error);
|
||||
res.status(500).json({ error: 'Failed to verify email' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get invitation token info
|
||||
router.get('/invitation/:token', async (req: Request, res: Response) => {
|
||||
const { token } = req.params;
|
||||
|
||||
try {
|
||||
const user = await userService.validateInvitationToken(token);
|
||||
if (!user) {
|
||||
return res.status(400).json({ error: 'Invalid or expired invitation token' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
user: {
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Validate invitation error:', error);
|
||||
res.status(500).json({ error: 'Failed to validate invitation' });
|
||||
}
|
||||
});
|
||||
|
||||
// Accept invitation
|
||||
router.post('/accept-invitation', async (req: Request, res: Response) => {
|
||||
const { token, password } = req.body;
|
||||
|
||||
if (!token || !password) {
|
||||
return res.status(400).json({ error: 'Token and password are required' });
|
||||
}
|
||||
|
||||
// Validate password requirements
|
||||
const minLength = parseInt(process.env.PASSWORD_MIN_LENGTH || '8', 10);
|
||||
const requireUppercase = process.env.PASSWORD_REQUIRE_UPPERCASE === 'true';
|
||||
const requireLowercase = process.env.PASSWORD_REQUIRE_LOWERCASE === 'true';
|
||||
const requireNumber = process.env.PASSWORD_REQUIRE_NUMBER === 'true';
|
||||
|
||||
if (password.length < minLength) {
|
||||
return res.status(400).json({ error: `Password must be at least ${minLength} characters long` });
|
||||
}
|
||||
if (requireUppercase && !/[A-Z]/.test(password)) {
|
||||
return res.status(400).json({ error: 'Password must contain at least one uppercase letter' });
|
||||
}
|
||||
if (requireLowercase && !/[a-z]/.test(password)) {
|
||||
return res.status(400).json({ error: 'Password must contain at least one lowercase letter' });
|
||||
}
|
||||
if (requireNumber && !/[0-9]/.test(password)) {
|
||||
return res.status(400).json({ error: 'Password must contain at least one number' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await userService.acceptInvitation(token, password);
|
||||
if (user) {
|
||||
res.json({ success: true, message: 'Invitation accepted successfully', user });
|
||||
} else {
|
||||
res.status(400).json({ error: 'Invalid or expired invitation token' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Accept invitation error:', error);
|
||||
res.status(500).json({ error: 'Failed to accept invitation' });
|
||||
}
|
||||
});
|
||||
|
||||
// Middleware to extract session and attach user to request
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||
|
||||
// Debug logging for cookie issues
|
||||
if (req.path === '/api/auth/me') {
|
||||
logger.debug(`[authMiddleware] Path: ${req.path}, Cookies: ${JSON.stringify(req.cookies)}, SessionId from cookie: ${req.cookies?.sessionId}, SessionId from header: ${req.headers['x-session-id']}`);
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
const session = authService.getSession(sessionId);
|
||||
if (session) {
|
||||
req.sessionId = sessionId;
|
||||
req.user = session.user;
|
||||
req.accessToken = session.accessToken;
|
||||
try {
|
||||
const session = await authService.getSession(sessionId);
|
||||
if (session) {
|
||||
req.sessionId = sessionId;
|
||||
req.user = session.user;
|
||||
req.accessToken = session.accessToken;
|
||||
} else {
|
||||
logger.debug(`[authMiddleware] Session not found for sessionId: ${sessionId.substring(0, 8)}...`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Auth middleware error:', error);
|
||||
}
|
||||
} else {
|
||||
if (req.path === '/api/auth/me') {
|
||||
logger.debug(`[authMiddleware] No sessionId found in cookies or headers for ${req.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Middleware to require authentication
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
// If OAuth is enabled, require a valid session
|
||||
if (authService.isOAuthEnabled()) {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
}
|
||||
// If only service account is configured, allow through
|
||||
else if (!authService.isUsingServiceAccount()) {
|
||||
return res.status(503).json({ error: 'No authentication method configured' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
// Re-export authorization middleware for convenience
|
||||
export { requireAuth, requireRole, requirePermission, requireAdmin } from '../middleware/authorization.js';
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -8,12 +8,17 @@ import { Router, Request, Response } from 'express';
|
||||
import { cacheStore } from '../services/cacheStore.js';
|
||||
import { syncEngine } from '../services/syncEngine.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
import { getQueryString, getParamString } from '../utils/queryHelpers.js';
|
||||
import { OBJECT_TYPES } from '../generated/jira-schema.js';
|
||||
import type { CMDBObjectTypeName } from '../generated/jira-types.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication and manage_settings permission
|
||||
router.use(requireAuth);
|
||||
router.use(requirePermission('manage_settings'));
|
||||
|
||||
// Get cache status
|
||||
router.get('/status', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -4,21 +4,50 @@ import { dataService } from '../services/dataService.js';
|
||||
import { databaseService } from '../services/database.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { config } from '../config/env.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
import { getQueryString, getQueryNumber, getParamString } from '../utils/queryHelpers.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get AI classification for an application
|
||||
router.post('/suggest/:id', async (req: Request, res: Response) => {
|
||||
// All routes require authentication
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get AI classification for an application (requires search permission)
|
||||
router.post('/suggest/:id', requirePermission('search'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = getParamString(req, 'id');
|
||||
// Get provider from query parameter or request body, default to config
|
||||
const provider = (getQueryString(req, 'provider') as AIProvider) || (req.body.provider as AIProvider) || config.defaultAIProvider;
|
||||
// Get provider from query parameter, request body, or user settings (default to 'claude')
|
||||
const userSettings = (req as any).userSettings;
|
||||
|
||||
// Check if AI is enabled for this user
|
||||
if (!userSettings?.ai_enabled) {
|
||||
res.status(403).json({
|
||||
error: 'AI functionality is disabled',
|
||||
message: 'AI functionality is not enabled in your profile settings. Please enable it in your user settings.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has selected an AI provider
|
||||
if (!userSettings?.ai_provider) {
|
||||
res.status(403).json({
|
||||
error: 'AI provider not configured',
|
||||
message: 'Please select an AI provider (Claude or OpenAI) in your user settings.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userDefaultProvider = userSettings.ai_provider === 'anthropic' ? 'claude' : userSettings.ai_provider === 'openai' ? 'openai' : 'claude';
|
||||
const provider = (getQueryString(req, 'provider') as AIProvider) || (req.body.provider as AIProvider) || (userDefaultProvider as AIProvider);
|
||||
|
||||
if (!aiService.isConfigured(provider)) {
|
||||
// Check if user has API key for the selected provider
|
||||
const hasApiKey = (provider === 'claude' && userSettings.ai_provider === 'anthropic' && !!userSettings.ai_api_key) ||
|
||||
(provider === 'openai' && userSettings.ai_provider === 'openai' && !!userSettings.ai_api_key);
|
||||
|
||||
if (!hasApiKey) {
|
||||
res.status(503).json({
|
||||
error: 'AI classification not available',
|
||||
message: `${provider === 'claude' ? 'Claude (Anthropic)' : 'OpenAI'} API is not configured. Please set ${provider === 'claude' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY'}.`
|
||||
message: `${provider === 'claude' ? 'Claude (Anthropic)' : 'OpenAI'} API key is not configured. Please configure the API key in your user settings.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -29,8 +58,15 @@ router.post('/suggest/:id', async (req: Request, res: Response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user API keys from user settings (already loaded above)
|
||||
const userApiKeys = userSettings ? {
|
||||
anthropic: userSettings.ai_provider === 'anthropic' ? userSettings.ai_api_key : undefined,
|
||||
openai: userSettings.ai_provider === 'openai' ? userSettings.ai_api_key : undefined,
|
||||
tavily: userSettings.tavily_api_key,
|
||||
} : undefined;
|
||||
|
||||
logger.info(`Generating AI classification for: ${application.name} using ${provider}`);
|
||||
const suggestion = await aiService.classifyApplication(application, provider);
|
||||
const suggestion = await aiService.classifyApplication(application, provider, userApiKeys);
|
||||
|
||||
res.json(suggestion);
|
||||
} catch (error) {
|
||||
@@ -92,12 +128,16 @@ router.get('/stats', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// Check if AI is available - returns available providers
|
||||
router.get('/ai-status', (req: Request, res: Response) => {
|
||||
router.get('/ai-status', requireAuth, (req: Request, res: Response) => {
|
||||
const availableProviders = aiService.getAvailableProviders();
|
||||
// Get user's default provider from settings (default to 'claude')
|
||||
const userSettings = (req as any).userSettings;
|
||||
const userDefaultProvider = userSettings?.ai_provider === 'anthropic' ? 'claude' : userSettings?.ai_provider === 'openai' ? 'openai' : 'claude';
|
||||
|
||||
res.json({
|
||||
available: availableProviders.length > 0,
|
||||
providers: availableProviders,
|
||||
defaultProvider: config.defaultAIProvider,
|
||||
defaultProvider: userDefaultProvider,
|
||||
claude: {
|
||||
available: aiService.isProviderConfigured('claude'),
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
@@ -128,8 +168,8 @@ router.get('/prompt/:id', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Chat with AI about an application
|
||||
router.post('/chat/:id', async (req: Request, res: Response) => {
|
||||
// Chat with AI about an application (requires search permission)
|
||||
router.post('/chat/:id', requirePermission('search'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = getParamString(req, 'id');
|
||||
const { message, conversationId, provider: requestProvider } = req.body;
|
||||
@@ -139,12 +179,38 @@ router.post('/chat/:id', async (req: Request, res: Response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = (requestProvider as AIProvider) || config.defaultAIProvider;
|
||||
// Get provider from request or user settings (default to 'claude')
|
||||
const userSettings = (req as any).userSettings;
|
||||
|
||||
// Check if AI is enabled for this user
|
||||
if (!userSettings?.ai_enabled) {
|
||||
res.status(403).json({
|
||||
error: 'AI functionality is disabled',
|
||||
message: 'AI functionality is not enabled in your profile settings. Please enable it in your user settings.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has selected an AI provider
|
||||
if (!userSettings?.ai_provider) {
|
||||
res.status(403).json({
|
||||
error: 'AI provider not configured',
|
||||
message: 'Please select an AI provider (Claude or OpenAI) in your user settings.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userDefaultProvider = userSettings.ai_provider === 'anthropic' ? 'claude' : userSettings.ai_provider === 'openai' ? 'openai' : 'claude';
|
||||
const provider = (requestProvider as AIProvider) || (userDefaultProvider as AIProvider);
|
||||
|
||||
if (!aiService.isConfigured(provider)) {
|
||||
// Check if user has API key for the selected provider
|
||||
const hasApiKey = (provider === 'claude' && userSettings.ai_provider === 'anthropic' && !!userSettings.ai_api_key) ||
|
||||
(provider === 'openai' && userSettings.ai_provider === 'openai' && !!userSettings.ai_api_key);
|
||||
|
||||
if (!hasApiKey) {
|
||||
res.status(503).json({
|
||||
error: 'AI chat not available',
|
||||
message: `${provider === 'claude' ? 'Claude (Anthropic)' : 'OpenAI'} API is not configured.`
|
||||
message: `${provider === 'claude' ? 'Claude (Anthropic)' : 'OpenAI'} API key is not configured. Please configure the API key in your user settings.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -155,8 +221,15 @@ router.post('/chat/:id', async (req: Request, res: Response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user API keys from user settings (already loaded above)
|
||||
const userApiKeys = userSettings ? {
|
||||
anthropic: userSettings.ai_provider === 'anthropic' ? userSettings.ai_api_key : undefined,
|
||||
openai: userSettings.ai_provider === 'openai' ? userSettings.ai_api_key : undefined,
|
||||
tavily: userSettings.tavily_api_key,
|
||||
} : undefined;
|
||||
|
||||
logger.info(`Chat message for: ${application.name} using ${provider}`);
|
||||
const response = await aiService.chat(application, message.trim(), conversationId, provider);
|
||||
const response = await aiService.chat(application, message.trim(), conversationId, provider, userApiKeys);
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { clearEffortCalculationConfigCache, getEffortCalculationConfigV25 } from '../services/effortCalculation.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
import type { EffortCalculationConfig, EffortCalculationConfigV25 } from '../config/effortCalculation.js';
|
||||
import type { DataCompletenessConfig } from '../types/index.js';
|
||||
|
||||
@@ -13,9 +14,13 @@ const __dirname = dirname(__filename);
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication and manage_settings permission
|
||||
router.use(requireAuth);
|
||||
router.use(requirePermission('manage_settings'));
|
||||
|
||||
// Path to the configuration files
|
||||
const CONFIG_FILE_PATH = join(__dirname, '../../data/effort-calculation-config.json');
|
||||
const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config-v25.json');
|
||||
const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config.json');
|
||||
const COMPLETENESS_CONFIG_FILE_PATH = join(__dirname, '../../data/data-completeness-config.json');
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import { databaseService } from '../services/database.js';
|
||||
import { syncEngine } from '../services/syncEngine.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { validateApplicationConfiguration, calculateRequiredEffortWithMinMax } from '../services/effortCalculation.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
import type { ApplicationDetails, ApplicationStatus, ReferenceValue } from '../types/index.js';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
@@ -11,6 +12,10 @@ import { fileURLToPath } from 'url';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication and view_reports permission
|
||||
router.use(requireAuth);
|
||||
router.use(requirePermission('view_reports'));
|
||||
|
||||
// Simple in-memory cache for dashboard stats
|
||||
interface CachedStats {
|
||||
data: unknown;
|
||||
@@ -778,6 +783,7 @@ router.get('/data-completeness', async (req: Request, res: Response) => {
|
||||
byField: byFieldArray,
|
||||
byApplication,
|
||||
byTeam: byTeamArray,
|
||||
config: completenessConfig, // Include config so frontend doesn't need to fetch it separately
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get data completeness', error);
|
||||
|
||||
@@ -7,12 +7,17 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { cmdbService } from '../services/cmdbService.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
import { getQueryString, getQueryNumber, getParamString } from '../utils/queryHelpers.js';
|
||||
import { OBJECT_TYPES } from '../generated/jira-schema.js';
|
||||
import type { CMDBObject, CMDBObjectTypeName } from '../generated/jira-types.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication and search permission
|
||||
router.use(requireAuth);
|
||||
router.use(requirePermission('search'));
|
||||
|
||||
// Get list of supported object types
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
const types = Object.entries(OBJECT_TYPES).map(([typeName, def]) => ({
|
||||
|
||||
117
backend/src/routes/profile.ts
Normal file
117
backend/src/routes/profile.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Profile Routes
|
||||
*
|
||||
* Routes for user profile management (users can manage their own profile).
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { userService } from '../services/userService.js';
|
||||
import { requireAuth } from '../middleware/authorization.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get current user profile
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user || !('id' in req.user)) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const user = await userService.getUserById(req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Don't return sensitive data
|
||||
const { password_hash, password_reset_token, password_reset_expires, email_verification_token, ...safeUser } = user;
|
||||
|
||||
res.json(safeUser);
|
||||
} catch (error) {
|
||||
logger.error('Get profile error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch profile' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update profile
|
||||
router.put('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user || !('id' in req.user)) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { username, display_name } = req.body;
|
||||
|
||||
const user = await userService.updateUser(req.user.id, {
|
||||
username,
|
||||
display_name,
|
||||
});
|
||||
|
||||
// Don't return sensitive data
|
||||
const { password_hash, password_reset_token, password_reset_expires, email_verification_token, ...safeUser } = user;
|
||||
|
||||
res.json(safeUser);
|
||||
} catch (error) {
|
||||
logger.error('Update profile error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to update profile';
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Change password
|
||||
router.put('/password', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user || !('id' in req.user)) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { current_password, new_password } = req.body;
|
||||
|
||||
if (!current_password || !new_password) {
|
||||
return res.status(400).json({ error: 'Current password and new password are required' });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const user = await userService.getUserById(req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const isValid = await userService.verifyPassword(current_password, user.password_hash);
|
||||
if (!isValid) {
|
||||
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
// Validate new password requirements
|
||||
const minLength = parseInt(process.env.PASSWORD_MIN_LENGTH || '8', 10);
|
||||
const requireUppercase = process.env.PASSWORD_REQUIRE_UPPERCASE === 'true';
|
||||
const requireLowercase = process.env.PASSWORD_REQUIRE_LOWERCASE === 'true';
|
||||
const requireNumber = process.env.PASSWORD_REQUIRE_NUMBER === 'true';
|
||||
|
||||
if (new_password.length < minLength) {
|
||||
return res.status(400).json({ error: `Password must be at least ${minLength} characters long` });
|
||||
}
|
||||
if (requireUppercase && !/[A-Z]/.test(new_password)) {
|
||||
return res.status(400).json({ error: 'Password must contain at least one uppercase letter' });
|
||||
}
|
||||
if (requireLowercase && !/[a-z]/.test(new_password)) {
|
||||
return res.status(400).json({ error: 'Password must contain at least one lowercase letter' });
|
||||
}
|
||||
if (requireNumber && !/[0-9]/.test(new_password)) {
|
||||
return res.status(400).json({ error: 'Password must contain at least one number' });
|
||||
}
|
||||
|
||||
// Update password
|
||||
await userService.updatePassword(req.user.id, new_password);
|
||||
|
||||
res.json({ success: true, message: 'Password updated successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Change password error:', error);
|
||||
res.status(500).json({ error: 'Failed to change password' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { dataService } from '../services/dataService.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { requireAuth } from '../middleware/authorization.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get all reference data
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
196
backend/src/routes/roles.ts
Normal file
196
backend/src/routes/roles.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Role Management Routes
|
||||
*
|
||||
* Routes for managing roles and permissions (admin only).
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { roleService } from '../services/roleService.js';
|
||||
import { requireAuth, requireAdmin } from '../middleware/authorization.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get all roles (public, but permissions are admin-only)
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const roles = await roleService.getAllRoles();
|
||||
|
||||
// Get permissions for each role
|
||||
const rolesWithPermissions = await Promise.all(
|
||||
roles.map(async (role) => {
|
||||
const permissions = await roleService.getRolePermissions(role.id);
|
||||
return {
|
||||
...role,
|
||||
permissions: permissions.map(p => ({ id: p.id, name: p.name, description: p.description, resource: p.resource })),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json(rolesWithPermissions);
|
||||
} catch (error) {
|
||||
logger.error('Get roles error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch roles' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get role by ID
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid role ID' });
|
||||
}
|
||||
|
||||
const role = await roleService.getRoleById(id);
|
||||
if (!role) {
|
||||
return res.status(404).json({ error: 'Role not found' });
|
||||
}
|
||||
|
||||
const permissions = await roleService.getRolePermissions(id);
|
||||
|
||||
res.json({
|
||||
...role,
|
||||
permissions: permissions.map(p => ({ id: p.id, name: p.name, description: p.description, resource: p.resource })),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get role error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch role' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create role (admin only)
|
||||
router.post('/', requireAuth, requireAdmin, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Role name is required' });
|
||||
}
|
||||
|
||||
const role = await roleService.createRole({ name, description });
|
||||
res.status(201).json(role);
|
||||
} catch (error) {
|
||||
logger.error('Create role error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to create role';
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update role (admin only)
|
||||
router.put('/:id', requireAuth, requireAdmin, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid role ID' });
|
||||
}
|
||||
|
||||
const { name, description } = req.body;
|
||||
|
||||
const role = await roleService.updateRole(id, { name, description });
|
||||
res.json(role);
|
||||
} catch (error) {
|
||||
logger.error('Update role error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to update role';
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete role (admin only)
|
||||
router.delete('/:id', requireAuth, requireAdmin, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid role ID' });
|
||||
}
|
||||
|
||||
const success = await roleService.deleteRole(id);
|
||||
if (success) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Role not found or cannot be deleted' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Delete role error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to delete role';
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get role permissions
|
||||
router.get('/:id/permissions', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid role ID' });
|
||||
}
|
||||
|
||||
const permissions = await roleService.getRolePermissions(id);
|
||||
res.json(permissions);
|
||||
} catch (error) {
|
||||
logger.error('Get role permissions error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch role permissions' });
|
||||
}
|
||||
});
|
||||
|
||||
// Assign permission to role (admin only)
|
||||
router.post('/:id/permissions', requireAuth, requireAdmin, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid role ID' });
|
||||
}
|
||||
|
||||
const { permission_id } = req.body;
|
||||
if (!permission_id) {
|
||||
return res.status(400).json({ error: 'permission_id is required' });
|
||||
}
|
||||
|
||||
const success = await roleService.assignPermissionToRole(id, permission_id);
|
||||
if (success) {
|
||||
const permissions = await roleService.getRolePermissions(id);
|
||||
res.json({ success: true, permissions: permissions.map(p => ({ id: p.id, name: p.name, description: p.description, resource: p.resource })) });
|
||||
} else {
|
||||
res.status(400).json({ error: 'Permission already assigned or invalid permission' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Assign permission error:', error);
|
||||
res.status(500).json({ error: 'Failed to assign permission' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove permission from role (admin only)
|
||||
router.delete('/:id/permissions/:permissionId', requireAuth, requireAdmin, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const roleId = parseInt(req.params.id, 10);
|
||||
const permissionId = parseInt(req.params.permissionId, 10);
|
||||
|
||||
if (isNaN(roleId) || isNaN(permissionId)) {
|
||||
return res.status(400).json({ error: 'Invalid role ID or permission ID' });
|
||||
}
|
||||
|
||||
const success = await roleService.removePermissionFromRole(roleId, permissionId);
|
||||
if (success) {
|
||||
const permissions = await roleService.getRolePermissions(roleId);
|
||||
res.json({ success: true, permissions: permissions.map(p => ({ id: p.id, name: p.name, description: p.description, resource: p.resource })) });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Permission not assigned to role' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Remove permission error:', error);
|
||||
res.status(500).json({ error: 'Failed to remove permission' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all permissions (public)
|
||||
router.get('/permissions/all', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const permissions = await roleService.getAllPermissions();
|
||||
res.json(permissions);
|
||||
} catch (error) {
|
||||
logger.error('Get permissions error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch permissions' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -4,10 +4,15 @@ import type { ObjectTypeDefinition, AttributeDefinition } from '../generated/jir
|
||||
import { dataService } from '../services/dataService.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { jiraAssetsClient } from '../services/jiraAssetsClient.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
import type { CMDBObjectTypeName } from '../generated/jira-types.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication and search permission
|
||||
router.use(requireAuth);
|
||||
router.use(requirePermission('search'));
|
||||
|
||||
// Extended types for API response
|
||||
interface ObjectTypeWithLinks extends ObjectTypeDefinition {
|
||||
incomingLinks: Array<{
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { cmdbService } from '../services/cmdbService.js';
|
||||
import { jiraAssetsService } from '../services/jiraAssets.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { config } from '../config/env.js';
|
||||
import { requireAuth, requirePermission } from '../middleware/authorization.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// CMDB free-text search endpoint (from cache)
|
||||
// All routes require authentication and search permission
|
||||
router.use(requireAuth);
|
||||
router.use(requirePermission('search'));
|
||||
|
||||
// CMDB free-text search endpoint (using Jira API)
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const query = req.query.query as string;
|
||||
@@ -18,53 +23,37 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
|
||||
logger.info(`CMDB search request: query="${query}", limit=${limit}`);
|
||||
|
||||
// Search all types in cache
|
||||
const results = await cmdbService.searchAllTypes(query.trim(), { limit });
|
||||
|
||||
// Group results by object type
|
||||
const objectTypeMap = new Map<string, { id: number; name: string; iconUrl: string }>();
|
||||
const formattedResults = results.map(obj => {
|
||||
const typeName = obj._objectType || 'Unknown';
|
||||
|
||||
// Track unique object types
|
||||
if (!objectTypeMap.has(typeName)) {
|
||||
objectTypeMap.set(typeName, {
|
||||
id: objectTypeMap.size + 1,
|
||||
name: typeName,
|
||||
iconUrl: '', // Can be enhanced to include actual icons
|
||||
});
|
||||
// Set user token on jiraAssetsService (same logic as middleware)
|
||||
// Use OAuth token if available, otherwise user's PAT, otherwise service account token
|
||||
if (req.accessToken) {
|
||||
jiraAssetsService.setRequestToken(req.accessToken);
|
||||
} else if (req.user && 'id' in req.user) {
|
||||
const userSettings = (req as any).userSettings;
|
||||
if (userSettings?.jira_pat) {
|
||||
jiraAssetsService.setRequestToken(userSettings.jira_pat);
|
||||
} else if (config.jiraServiceAccountToken) {
|
||||
jiraAssetsService.setRequestToken(config.jiraServiceAccountToken);
|
||||
} else {
|
||||
jiraAssetsService.setRequestToken(null);
|
||||
}
|
||||
} else {
|
||||
jiraAssetsService.setRequestToken(config.jiraServiceAccountToken || null);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use Jira API search (searches Key, Object Type, Label, Name, Description, Status)
|
||||
// The URL will be logged automatically by jiraAssetsService.searchCMDB()
|
||||
const response = await jiraAssetsService.searchCMDB(query.trim(), limit);
|
||||
|
||||
const objectType = objectTypeMap.get(typeName)!;
|
||||
// Clear token after request
|
||||
jiraAssetsService.clearRequestToken();
|
||||
|
||||
return {
|
||||
id: parseInt(obj.id, 10) || 0,
|
||||
key: obj.objectKey,
|
||||
label: obj.label,
|
||||
objectTypeId: objectType.id,
|
||||
avatarUrl: '',
|
||||
attributes: [], // Can be enhanced to include attributes
|
||||
};
|
||||
});
|
||||
|
||||
// Build response matching CMDBSearchResponse interface
|
||||
const response = {
|
||||
metadata: {
|
||||
count: formattedResults.length,
|
||||
offset: 0,
|
||||
limit: limit,
|
||||
total: formattedResults.length,
|
||||
criteria: {
|
||||
query: query,
|
||||
type: 'global',
|
||||
schema: parseInt(config.jiraSchemaId, 10) || 0,
|
||||
},
|
||||
},
|
||||
objectTypes: Array.from(objectTypeMap.values()),
|
||||
results: formattedResults,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
// Clear token on error
|
||||
jiraAssetsService.clearRequestToken();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('CMDB search failed', error);
|
||||
res.status(500).json({ error: 'Failed to search CMDB' });
|
||||
|
||||
105
backend/src/routes/userSettings.ts
Normal file
105
backend/src/routes/userSettings.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* User Settings Routes
|
||||
*
|
||||
* Routes for managing user-specific settings (Jira PAT, AI features, etc.).
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { userSettingsService } from '../services/userSettingsService.js';
|
||||
import { requireAuth } from '../middleware/authorization.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get current user settings
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user || !('id' in req.user)) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const settings = await userSettingsService.getUserSettings(req.user.id);
|
||||
if (!settings) {
|
||||
return res.status(404).json({ error: 'Settings not found' });
|
||||
}
|
||||
|
||||
// Don't return sensitive data in full
|
||||
res.json({
|
||||
...settings,
|
||||
jira_pat: settings.jira_pat ? '***' : null, // Mask PAT
|
||||
ai_api_key: settings.ai_api_key ? '***' : null, // Mask API key
|
||||
tavily_api_key: settings.tavily_api_key ? '***' : null, // Mask API key
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get user settings error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch user settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update user settings
|
||||
router.put('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user || !('id' in req.user)) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { jira_pat, ai_enabled, ai_provider, ai_api_key, web_search_enabled, tavily_api_key } = req.body;
|
||||
|
||||
const settings = await userSettingsService.updateUserSettings(req.user.id, {
|
||||
jira_pat,
|
||||
ai_enabled,
|
||||
ai_provider,
|
||||
ai_api_key,
|
||||
web_search_enabled,
|
||||
tavily_api_key,
|
||||
});
|
||||
|
||||
// Don't return sensitive data in full
|
||||
res.json({
|
||||
...settings,
|
||||
jira_pat: settings.jira_pat ? '***' : null,
|
||||
ai_api_key: settings.ai_api_key ? '***' : null,
|
||||
tavily_api_key: settings.tavily_api_key ? '***' : null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Update user settings error:', error);
|
||||
res.status(500).json({ error: 'Failed to update user settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Validate Jira PAT
|
||||
router.post('/jira-pat/validate', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user || !('id' in req.user)) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { pat } = req.body;
|
||||
const isValid = await userSettingsService.validateJiraPat(req.user.id, pat);
|
||||
|
||||
res.json({ valid: isValid });
|
||||
} catch (error) {
|
||||
logger.error('Validate Jira PAT error:', error);
|
||||
res.status(500).json({ error: 'Failed to validate Jira PAT' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get Jira PAT status
|
||||
router.get('/jira-pat/status', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user || !('id' in req.user)) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const status = await userSettingsService.getJiraPatStatus(req.user.id);
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
logger.error('Get Jira PAT status error:', error);
|
||||
res.status(500).json({ error: 'Failed to get Jira PAT status' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
309
backend/src/routes/users.ts
Normal file
309
backend/src/routes/users.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* User Management Routes
|
||||
*
|
||||
* Routes for managing users (admin only).
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { userService } from '../services/userService.js';
|
||||
import { roleService } from '../services/roleService.js';
|
||||
import { requireAuth, requireAdmin } from '../middleware/authorization.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication and admin role
|
||||
router.use(requireAuth);
|
||||
router.use(requireAdmin);
|
||||
|
||||
// Get all users
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const users = await userService.getAllUsers();
|
||||
|
||||
// Get roles for each user
|
||||
const usersWithRoles = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const roles = await userService.getUserRoles(user.id);
|
||||
return {
|
||||
...user,
|
||||
roles: roles.map(r => ({ id: r.id, name: r.name, description: r.description })),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json(usersWithRoles);
|
||||
} catch (error) {
|
||||
logger.error('Get users error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch users' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user by ID
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid user ID' });
|
||||
}
|
||||
|
||||
const user = await userService.getUserById(id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const roles = await userService.getUserRoles(user.id);
|
||||
const permissions = await roleService.getUserPermissions(user.id);
|
||||
|
||||
res.json({
|
||||
...user,
|
||||
roles: roles.map(r => ({ id: r.id, name: r.name, description: r.description })),
|
||||
permissions: permissions.map(p => ({ id: p.id, name: p.name, description: p.description })),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get user error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create user
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email, username, password, display_name, send_invitation } = req.body;
|
||||
|
||||
if (!email || !username) {
|
||||
return res.status(400).json({ error: 'Email and username are required' });
|
||||
}
|
||||
|
||||
const user = await userService.createUser({
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
display_name,
|
||||
send_invitation: send_invitation !== false, // Default to true
|
||||
});
|
||||
|
||||
const roles = await userService.getUserRoles(user.id);
|
||||
|
||||
res.status(201).json({
|
||||
...user,
|
||||
roles: roles.map(r => ({ id: r.id, name: r.name, description: r.description })),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Create user error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to create user';
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update user
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid user ID' });
|
||||
}
|
||||
|
||||
const { email, username, display_name, is_active } = req.body;
|
||||
|
||||
const user = await userService.updateUser(id, {
|
||||
email,
|
||||
username,
|
||||
display_name,
|
||||
is_active,
|
||||
});
|
||||
|
||||
const roles = await userService.getUserRoles(user.id);
|
||||
|
||||
res.json({
|
||||
...user,
|
||||
roles: roles.map(r => ({ id: r.id, name: r.name, description: r.description })),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Update user error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to update user';
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete user
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid user ID' });
|
||||
}
|
||||
|
||||
// Prevent deleting yourself
|
||||
if (req.user && 'id' in req.user && req.user.id === id) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
}
|
||||
|
||||
const success = await userService.deleteUser(id);
|
||||
if (success) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Delete user error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Send invitation email
|
||||
router.post('/:id/invite', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid user ID' });
|
||||
}
|
||||
|
||||
const success = await userService.sendInvitation(id);
|
||||
if (success) {
|
||||
res.json({ success: true, message: 'Invitation sent successfully' });
|
||||
} else {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Send invitation error:', error);
|
||||
res.status(500).json({ error: 'Failed to send invitation' });
|
||||
}
|
||||
});
|
||||
|
||||
// Assign role to user
|
||||
router.post('/:id/roles', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid user ID' });
|
||||
}
|
||||
|
||||
const { role_id } = req.body;
|
||||
if (!role_id) {
|
||||
return res.status(400).json({ error: 'role_id is required' });
|
||||
}
|
||||
|
||||
const success = await userService.assignRole(id, role_id);
|
||||
if (success) {
|
||||
const roles = await userService.getUserRoles(id);
|
||||
res.json({ success: true, roles: roles.map(r => ({ id: r.id, name: r.name, description: r.description })) });
|
||||
} else {
|
||||
res.status(400).json({ error: 'Role already assigned or invalid role' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Assign role error:', error);
|
||||
res.status(500).json({ error: 'Failed to assign role' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove role from user
|
||||
router.delete('/:id/roles/:roleId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
const roleId = parseInt(req.params.roleId, 10);
|
||||
|
||||
if (isNaN(userId) || isNaN(roleId)) {
|
||||
return res.status(400).json({ error: 'Invalid user ID or role ID' });
|
||||
}
|
||||
|
||||
// Prevent removing administrator role from yourself
|
||||
if (req.user && 'id' in req.user && req.user.id === userId) {
|
||||
const role = await roleService.getRoleById(roleId);
|
||||
if (role && role.name === 'administrator') {
|
||||
return res.status(400).json({ error: 'Cannot remove administrator role from your own account' });
|
||||
}
|
||||
}
|
||||
|
||||
const success = await userService.removeRole(userId, roleId);
|
||||
if (success) {
|
||||
const roles = await userService.getUserRoles(userId);
|
||||
res.json({ success: true, roles: roles.map(r => ({ id: r.id, name: r.name, description: r.description })) });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Role not assigned to user' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Remove role error:', error);
|
||||
res.status(500).json({ error: 'Failed to remove role' });
|
||||
}
|
||||
});
|
||||
|
||||
// Activate/deactivate user
|
||||
router.put('/:id/activate', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid user ID' });
|
||||
}
|
||||
|
||||
const { is_active } = req.body;
|
||||
if (typeof is_active !== 'boolean') {
|
||||
return res.status(400).json({ error: 'is_active must be a boolean' });
|
||||
}
|
||||
|
||||
// Prevent deactivating yourself
|
||||
if (req.user && 'id' in req.user && req.user.id === id && !is_active) {
|
||||
return res.status(400).json({ error: 'Cannot deactivate your own account' });
|
||||
}
|
||||
|
||||
const user = await userService.updateUser(id, { is_active });
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
logger.error('Activate user error:', error);
|
||||
res.status(500).json({ error: 'Failed to update user status' });
|
||||
}
|
||||
});
|
||||
|
||||
// Manually verify email address (admin action)
|
||||
router.put('/:id/verify-email', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid user ID' });
|
||||
}
|
||||
|
||||
await userService.manuallyVerifyEmail(id);
|
||||
const user = await userService.getUserById(id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const roles = await userService.getUserRoles(user.id);
|
||||
res.json({
|
||||
...user,
|
||||
roles: roles.map(r => ({ id: r.id, name: r.name, description: r.description })),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Verify email error:', error);
|
||||
res.status(500).json({ error: 'Failed to verify email' });
|
||||
}
|
||||
});
|
||||
|
||||
// Set password for user (admin action)
|
||||
router.put('/:id/password', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid user ID' });
|
||||
}
|
||||
|
||||
const { password } = req.body;
|
||||
if (!password || typeof password !== 'string') {
|
||||
return res.status(400).json({ error: 'Password is required' });
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
|
||||
}
|
||||
|
||||
await userService.updatePassword(id, password);
|
||||
logger.info(`Password set by admin for user: ${id}`);
|
||||
|
||||
res.json({ success: true, message: 'Password updated successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Set password error:', error);
|
||||
res.status(500).json({ error: 'Failed to set password' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,13 +1,20 @@
|
||||
import { config } from '../config/env.js';
|
||||
import { logger } from './logger.js';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { getAuthDatabase } from './database/migrations.js';
|
||||
import { userService, type User } from './userService.js';
|
||||
import { roleService } from './roleService.js';
|
||||
|
||||
// Token storage (in production, use Redis or similar)
|
||||
interface UserSession {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt: number;
|
||||
user: JiraUser;
|
||||
// Extended user interface for sessions
|
||||
export interface SessionUser {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
emailAddress?: string;
|
||||
avatarUrl?: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface JiraUser {
|
||||
@@ -17,19 +24,21 @@ export interface JiraUser {
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
// In-memory session store (replace with Redis in production)
|
||||
const sessionStore = new Map<string, UserSession>();
|
||||
interface DatabaseSession {
|
||||
id: string;
|
||||
user_id: number | null;
|
||||
auth_method: string;
|
||||
access_token: string | null;
|
||||
refresh_token: string | null;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
}
|
||||
|
||||
// Session cleanup interval (every 5 minutes)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [sessionId, session] of sessionStore.entries()) {
|
||||
if (session.expiresAt < now) {
|
||||
sessionStore.delete(sessionId);
|
||||
logger.debug(`Cleaned up expired session: ${sessionId.substring(0, 8)}...`);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
const isPostgres = (): boolean => {
|
||||
return process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql';
|
||||
};
|
||||
|
||||
// PKCE helpers for OAuth 2.0
|
||||
export function generateCodeVerifier(): string {
|
||||
@@ -59,8 +68,192 @@ setInterval(() => {
|
||||
}
|
||||
}, 60 * 1000);
|
||||
|
||||
// Clean up expired sessions from database
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const db = getAuthDatabase();
|
||||
const now = new Date().toISOString();
|
||||
await db.execute(
|
||||
'DELETE FROM sessions WHERE expires_at < ?',
|
||||
[now]
|
||||
);
|
||||
await db.close();
|
||||
} catch (error) {
|
||||
logger.error('Failed to clean up expired sessions:', error);
|
||||
}
|
||||
}, 5 * 60 * 1000); // Every 5 minutes
|
||||
|
||||
class AuthService {
|
||||
// Get OAuth authorization URL
|
||||
/**
|
||||
* Get session duration in milliseconds
|
||||
*/
|
||||
private getSessionDuration(): number {
|
||||
const hours = parseInt(process.env.SESSION_DURATION_HOURS || '24', 10);
|
||||
return hours * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session in the database
|
||||
*/
|
||||
private async createSession(
|
||||
userId: number | null,
|
||||
authMethod: 'local' | 'oauth' | 'jira-oauth',
|
||||
accessToken?: string,
|
||||
refreshToken?: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Promise<string> {
|
||||
const db = getAuthDatabase();
|
||||
const sessionId = randomBytes(32).toString('hex');
|
||||
const now = new Date().toISOString();
|
||||
const expiresAt = new Date(Date.now() + this.getSessionDuration()).toISOString();
|
||||
|
||||
try {
|
||||
await db.execute(
|
||||
`INSERT INTO sessions (
|
||||
id, user_id, auth_method, access_token, refresh_token,
|
||||
expires_at, created_at, ip_address, user_agent
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
sessionId,
|
||||
userId,
|
||||
authMethod,
|
||||
accessToken || null,
|
||||
refreshToken || null,
|
||||
expiresAt,
|
||||
now,
|
||||
ipAddress || null,
|
||||
userAgent || null,
|
||||
]
|
||||
);
|
||||
|
||||
logger.info(`Session created: ${sessionId.substring(0, 8)}... (${authMethod})`);
|
||||
return sessionId;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session from database
|
||||
*/
|
||||
private async getSessionFromDb(sessionId: string): Promise<DatabaseSession | null> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
const session = await db.queryOne<DatabaseSession>(
|
||||
'SELECT * FROM sessions WHERE id = ?',
|
||||
[sessionId]
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (new Date(session.expires_at) < new Date()) {
|
||||
await db.execute('DELETE FROM sessions WHERE id = ?', [sessionId]);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user from session (local auth)
|
||||
*/
|
||||
async getSessionUser(sessionId: string): Promise<SessionUser | null> {
|
||||
const session = await this.getSessionFromDb(sessionId);
|
||||
if (!session || !session.user_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await userService.getUserById(session.user_id);
|
||||
if (!user || !user.is_active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const roles = await roleService.getUserRoles(session.user_id);
|
||||
const permissions = await roleService.getUserPermissions(session.user_id);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.display_name || user.username,
|
||||
emailAddress: user.email,
|
||||
roles: roles.map(r => r.name),
|
||||
permissions: permissions.map(p => p.name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Local login (email or username/password)
|
||||
*/
|
||||
async localLogin(
|
||||
email: string,
|
||||
password: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Promise<{ sessionId: string; user: SessionUser }> {
|
||||
logger.debug(`[localLogin] Attempting login with identifier: ${email}`);
|
||||
|
||||
// Try email first, then username if email lookup fails
|
||||
let user = await userService.getUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
logger.debug(`[localLogin] Email lookup failed, trying username: ${email}`);
|
||||
// If email lookup failed, try username
|
||||
user = await userService.getUserByUsername(email);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`[localLogin] User not found: ${email}`);
|
||||
throw new Error('Invalid email/username or password');
|
||||
}
|
||||
|
||||
logger.debug(`[localLogin] User found: ${user.email} (${user.username}), active: ${user.is_active}, verified: ${user.email_verified}`);
|
||||
|
||||
if (!user.is_active) {
|
||||
logger.warn(`[localLogin] Account is deactivated: ${user.email}`);
|
||||
throw new Error('Account is deactivated');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await userService.verifyPassword(password, user.password_hash);
|
||||
if (!isValid) {
|
||||
logger.warn(`[localLogin] Invalid password for user: ${user.email}`);
|
||||
throw new Error('Invalid email/username or password');
|
||||
}
|
||||
|
||||
logger.info(`[localLogin] Successful login: ${user.email} (${user.username})`);
|
||||
|
||||
// Update last login
|
||||
await userService.updateLastLogin(user.id);
|
||||
|
||||
// Create session
|
||||
const sessionId = await this.createSession(
|
||||
user.id,
|
||||
'local',
|
||||
undefined,
|
||||
undefined,
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
|
||||
const sessionUser = await this.getSessionUser(sessionId);
|
||||
if (!sessionUser) {
|
||||
throw new Error('Failed to create session');
|
||||
}
|
||||
|
||||
return { sessionId, user: sessionUser };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth authorization URL
|
||||
*/
|
||||
getAuthorizationUrl(): { url: string; state: string } {
|
||||
const state = generateState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
@@ -86,8 +279,15 @@ class AuthService {
|
||||
return { url: authUrl, state };
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
async exchangeCodeForTokens(code: string, state: string): Promise<{ sessionId: string; user: JiraUser }> {
|
||||
/**
|
||||
* Exchange authorization code for tokens (Jira OAuth)
|
||||
*/
|
||||
async exchangeCodeForTokens(
|
||||
code: string,
|
||||
state: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Promise<{ sessionId: string; user: SessionUser | JiraUser }> {
|
||||
// Retrieve and validate state
|
||||
const flowData = authFlowStore.get(state);
|
||||
if (!flowData) {
|
||||
@@ -129,25 +329,52 @@ class AuthService {
|
||||
token_type: string;
|
||||
};
|
||||
|
||||
// Fetch user info
|
||||
const user = await this.fetchUserInfo(tokenData.access_token);
|
||||
// Fetch user info from Jira
|
||||
const jiraUser = await this.fetchUserInfo(tokenData.access_token);
|
||||
|
||||
// Create session
|
||||
const sessionId = randomBytes(32).toString('hex');
|
||||
const session: UserSession = {
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token,
|
||||
expiresAt: Date.now() + (tokenData.expires_in * 1000),
|
||||
user,
|
||||
};
|
||||
// Try to find local user by email
|
||||
let localUser: User | null = null;
|
||||
if (jiraUser.emailAddress) {
|
||||
localUser = await userService.getUserByEmail(jiraUser.emailAddress);
|
||||
}
|
||||
|
||||
sessionStore.set(sessionId, session);
|
||||
logger.info(`Created session for user: ${user.displayName}`);
|
||||
if (localUser) {
|
||||
// Link OAuth to existing local user
|
||||
const sessionId = await this.createSession(
|
||||
localUser.id,
|
||||
'jira-oauth',
|
||||
tokenData.access_token,
|
||||
tokenData.refresh_token,
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
|
||||
return { sessionId, user };
|
||||
const sessionUser = await this.getSessionUser(sessionId);
|
||||
if (!sessionUser) {
|
||||
throw new Error('Failed to create session');
|
||||
}
|
||||
|
||||
logger.info(`OAuth login successful for local user: ${localUser.email}`);
|
||||
return { sessionId, user: sessionUser };
|
||||
} else {
|
||||
// Create session without local user (OAuth-only)
|
||||
const sessionId = await this.createSession(
|
||||
null,
|
||||
'jira-oauth',
|
||||
tokenData.access_token,
|
||||
tokenData.refresh_token,
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
|
||||
logger.info(`OAuth login successful for Jira user: ${jiraUser.displayName}`);
|
||||
return { sessionId, user: jiraUser };
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch current user info from Jira
|
||||
/**
|
||||
* Fetch current user info from Jira
|
||||
*/
|
||||
async fetchUserInfo(accessToken: string): Promise<JiraUser> {
|
||||
const response = await fetch(`${config.jiraHost}/rest/api/2/myself`, {
|
||||
headers: {
|
||||
@@ -177,38 +404,54 @@ class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
// Get session by ID
|
||||
getSession(sessionId: string): UserSession | null {
|
||||
const session = sessionStore.get(sessionId);
|
||||
/**
|
||||
* Get session by ID
|
||||
*/
|
||||
async getSession(sessionId: string): Promise<{ user: SessionUser | JiraUser; accessToken?: string } | null> {
|
||||
const session = await this.getSessionFromDb(sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (session.expiresAt < Date.now()) {
|
||||
sessionStore.delete(sessionId);
|
||||
return null;
|
||||
if (session.user_id) {
|
||||
// Local user session
|
||||
const user = await this.getSessionUser(sessionId);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
return { user };
|
||||
} else if (session.access_token) {
|
||||
// OAuth-only session
|
||||
const user = await this.fetchUserInfo(session.access_token);
|
||||
return { user, accessToken: session.access_token };
|
||||
}
|
||||
|
||||
return session;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get access token for a session
|
||||
getAccessToken(sessionId: string): string | null {
|
||||
const session = this.getSession(sessionId);
|
||||
return session?.accessToken || null;
|
||||
/**
|
||||
* Get access token for a session
|
||||
*/
|
||||
async getAccessToken(sessionId: string): Promise<string | null> {
|
||||
const session = await this.getSessionFromDb(sessionId);
|
||||
return session?.access_token || null;
|
||||
}
|
||||
|
||||
// Get user for a session
|
||||
/**
|
||||
* Get user for a session (legacy method for compatibility)
|
||||
*/
|
||||
getUser(sessionId: string): JiraUser | null {
|
||||
const session = this.getSession(sessionId);
|
||||
return session?.user || null;
|
||||
// This is a legacy method - use getSessionUser or getSession instead
|
||||
// For now, return null to maintain compatibility
|
||||
return null;
|
||||
}
|
||||
|
||||
// Refresh access token
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
async refreshAccessToken(sessionId: string): Promise<boolean> {
|
||||
const session = sessionStore.get(sessionId);
|
||||
if (!session?.refreshToken) {
|
||||
const session = await this.getSessionFromDb(sessionId);
|
||||
if (!session?.refresh_token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -218,7 +461,7 @@ class AuthService {
|
||||
grant_type: 'refresh_token',
|
||||
client_id: config.jiraOAuthClientId,
|
||||
client_secret: config.jiraOAuthClientSecret,
|
||||
refresh_token: session.refreshToken,
|
||||
refresh_token: session.refresh_token,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -241,16 +484,23 @@ class AuthService {
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
// Update session
|
||||
session.accessToken = tokenData.access_token;
|
||||
if (tokenData.refresh_token) {
|
||||
session.refreshToken = tokenData.refresh_token;
|
||||
// Update session in database
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
await db.execute(
|
||||
'UPDATE sessions SET access_token = ?, refresh_token = ?, expires_at = ? WHERE id = ?',
|
||||
[
|
||||
tokenData.access_token,
|
||||
tokenData.refresh_token || session.refresh_token,
|
||||
new Date(Date.now() + (tokenData.expires_in * 1000)).toISOString(),
|
||||
sessionId,
|
||||
]
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
session.expiresAt = Date.now() + (tokenData.expires_in * 1000);
|
||||
|
||||
sessionStore.set(sessionId, session);
|
||||
|
||||
logger.info(`Refreshed token for session: ${sessionId.substring(0, 8)}...`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Token refresh error:', error);
|
||||
@@ -258,28 +508,55 @@ class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// Logout / destroy session
|
||||
logout(sessionId: string): boolean {
|
||||
const existed = sessionStore.has(sessionId);
|
||||
sessionStore.delete(sessionId);
|
||||
if (existed) {
|
||||
logger.info(`Logged out session: ${sessionId.substring(0, 8)}...`);
|
||||
/**
|
||||
* Logout / destroy session
|
||||
*/
|
||||
async logout(sessionId: string): Promise<boolean> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
const result = await db.execute(
|
||||
'DELETE FROM sessions WHERE id = ?',
|
||||
[sessionId]
|
||||
);
|
||||
|
||||
if (result > 0) {
|
||||
logger.info(`Logged out session: ${sessionId.substring(0, 8)}...`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
return existed;
|
||||
}
|
||||
|
||||
// Check if OAuth is enabled (jiraAuthMethod = 'oauth')
|
||||
/**
|
||||
* Check if OAuth is enabled (jiraAuthMethod = 'oauth')
|
||||
*/
|
||||
isOAuthEnabled(): boolean {
|
||||
return config.jiraAuthMethod === 'oauth' && !!config.jiraOAuthClientId && !!config.jiraOAuthClientSecret;
|
||||
}
|
||||
|
||||
// Check if using service account (PAT) mode (jiraAuthMethod = 'pat')
|
||||
/**
|
||||
* Check if using service account (PAT) mode (jiraAuthMethod = 'pat')
|
||||
*/
|
||||
isUsingServiceAccount(): boolean {
|
||||
return config.jiraAuthMethod === 'pat' && !!config.jiraPat;
|
||||
// Service account mode is when auth method is PAT but no local auth is enabled
|
||||
// and no users exist (checked elsewhere)
|
||||
return config.jiraAuthMethod === 'pat';
|
||||
}
|
||||
|
||||
// Get the configured authentication method
|
||||
getAuthMethod(): 'pat' | 'oauth' | 'none' {
|
||||
/**
|
||||
* Check if local auth is enabled
|
||||
*/
|
||||
isLocalAuthEnabled(): boolean {
|
||||
return process.env.LOCAL_AUTH_ENABLED === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured authentication method
|
||||
*/
|
||||
getAuthMethod(): 'pat' | 'oauth' | 'local' | 'none' {
|
||||
if (this.isLocalAuthEnabled()) return 'local';
|
||||
if (this.isOAuthEnabled()) return 'oauth';
|
||||
if (this.isUsingServiceAccount()) return 'pat';
|
||||
return 'none';
|
||||
@@ -287,4 +564,3 @@ class AuthService {
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
|
||||
|
||||
@@ -337,8 +337,9 @@ interface TavilySearchResponse {
|
||||
}
|
||||
|
||||
// Perform web search using Tavily API
|
||||
async function performWebSearch(query: string): Promise<string | null> {
|
||||
if (!config.enableWebSearch || !config.tavilyApiKey) {
|
||||
async function performWebSearch(query: string, tavilyApiKey?: string): Promise<string | null> {
|
||||
// Tavily API key must be provided - it's configured in user profile settings
|
||||
if (!tavilyApiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -349,7 +350,7 @@ async function performWebSearch(query: string): Promise<string | null> {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api_key: config.tavilyApiKey,
|
||||
api_key: apiKey,
|
||||
query: query,
|
||||
search_depth: 'basic',
|
||||
include_answer: true,
|
||||
@@ -610,49 +611,56 @@ class AIService {
|
||||
private openaiClient: OpenAI | null = null;
|
||||
|
||||
constructor() {
|
||||
if (config.anthropicApiKey) {
|
||||
this.anthropicClient = new Anthropic({
|
||||
apiKey: config.anthropicApiKey,
|
||||
});
|
||||
logger.info('Anthropic (Claude) API configured');
|
||||
} else {
|
||||
logger.warn('Anthropic API key not configured. Claude classification will not work.');
|
||||
}
|
||||
|
||||
if (config.openaiApiKey) {
|
||||
this.openaiClient = new OpenAI({
|
||||
apiKey: config.openaiApiKey,
|
||||
});
|
||||
logger.info('OpenAI API configured');
|
||||
} else {
|
||||
logger.warn('OpenAI API key not configured. OpenAI classification will not work.');
|
||||
}
|
||||
// AI API keys are now configured per-user in their profile settings
|
||||
// Global clients are not initialized - clients are created per-request with user keys
|
||||
logger.info('AI service initialized - API keys must be configured in user profile settings');
|
||||
}
|
||||
|
||||
// Check if a specific provider is configured
|
||||
// Note: This now checks if user has configured the provider in their settings
|
||||
// The actual check should be done per-request with user API keys
|
||||
isProviderConfigured(provider: AIProvider): boolean {
|
||||
if (provider === 'claude') {
|
||||
return this.anthropicClient !== null;
|
||||
} else {
|
||||
return this.openaiClient !== null;
|
||||
}
|
||||
// Always return true - configuration is checked per-request with user keys
|
||||
// This maintains backward compatibility for the isConfigured() method
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get available providers
|
||||
getAvailableProviders(): AIProvider[] {
|
||||
const providers: AIProvider[] = [];
|
||||
if (this.anthropicClient) providers.push('claude');
|
||||
if (this.openaiClient) providers.push('openai');
|
||||
return providers;
|
||||
// Providers are available if users have configured API keys in their settings
|
||||
// This method is kept for backward compatibility but always returns both providers
|
||||
// The actual availability is checked per-request with user API keys
|
||||
return ['claude', 'openai'];
|
||||
}
|
||||
|
||||
async classifyApplication(application: ApplicationDetails, provider: AIProvider = config.defaultAIProvider): Promise<AISuggestion> {
|
||||
// Validate provider
|
||||
if (provider === 'claude' && !this.anthropicClient) {
|
||||
throw new Error('Claude API not configured. Please set ANTHROPIC_API_KEY.');
|
||||
async classifyApplication(
|
||||
application: ApplicationDetails,
|
||||
provider: AIProvider = 'claude', // Default to 'claude', but should be provided from user settings
|
||||
userApiKeys?: { anthropic?: string; openai?: string; tavily?: string }
|
||||
): Promise<AISuggestion> {
|
||||
// Use user API keys if provided, otherwise use global config
|
||||
// API keys must be provided via userApiKeys - they're configured in user profile settings
|
||||
const anthropicKey = userApiKeys?.anthropic;
|
||||
const openaiKey = userApiKeys?.openai;
|
||||
const tavilyKey = userApiKeys?.tavily;
|
||||
|
||||
// Create clients with user keys - API keys must be provided via userApiKeys
|
||||
let anthropicClient: Anthropic | null = null;
|
||||
let openaiClient: OpenAI | null = null;
|
||||
|
||||
if (anthropicKey) {
|
||||
anthropicClient = new Anthropic({ apiKey: anthropicKey });
|
||||
}
|
||||
if (provider === 'openai' && !this.openaiClient) {
|
||||
throw new Error('OpenAI API not configured. Please set OPENAI_API_KEY.');
|
||||
if (openaiKey) {
|
||||
openaiClient = new OpenAI({ apiKey: openaiKey });
|
||||
}
|
||||
|
||||
// Validate provider - API keys must be provided via userApiKeys
|
||||
if (provider === 'claude' && !anthropicKey) {
|
||||
throw new Error('Claude API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
if (provider === 'openai' && !openaiKey) {
|
||||
throw new Error('OpenAI API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
|
||||
// Check if web search is needed
|
||||
@@ -661,7 +669,7 @@ class AIService {
|
||||
logger.info(`Insufficient information detected for ${application.name}, performing web search...`);
|
||||
const supplierPart = application.supplierProduct ? `${application.supplierProduct} ` : '';
|
||||
const searchQuery = `${application.name} ${supplierPart}healthcare software`.trim();
|
||||
webSearchResults = await performWebSearch(searchQuery);
|
||||
webSearchResults = await performWebSearch(searchQuery, tavilyKey);
|
||||
if (webSearchResults) {
|
||||
logger.info(`Web search completed for ${application.name}`);
|
||||
} else {
|
||||
@@ -719,8 +727,12 @@ class AIService {
|
||||
let responseText: string;
|
||||
|
||||
if (provider === 'claude') {
|
||||
// Use Claude (Anthropic)
|
||||
const message = await this.anthropicClient!.messages.create({
|
||||
// Use Claude (Anthropic) - client created from user API key
|
||||
if (!anthropicClient) {
|
||||
throw new Error('Claude API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
const client = anthropicClient;
|
||||
const message = await client.messages.create({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
@@ -737,8 +749,12 @@ class AIService {
|
||||
}
|
||||
responseText = textBlock.text.trim();
|
||||
} else {
|
||||
// Use OpenAI
|
||||
const completion = await this.openaiClient!.chat.completions.create({
|
||||
// Use OpenAI - client created from user API key
|
||||
if (!openaiClient) {
|
||||
throw new Error('OpenAI API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
const client = openaiClient;
|
||||
const completion = await client.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
@@ -884,7 +900,7 @@ class AIService {
|
||||
async classifyBatch(
|
||||
applications: ApplicationDetails[],
|
||||
onProgress?: (completed: number, total: number) => void,
|
||||
provider: AIProvider = config.defaultAIProvider
|
||||
provider: AIProvider = 'claude' // Default to 'claude', but should be provided from user settings
|
||||
): Promise<Map<string, AISuggestion>> {
|
||||
const results = new Map<string, AISuggestion>();
|
||||
const total = applications.length;
|
||||
@@ -936,8 +952,9 @@ class AIService {
|
||||
if (provider) {
|
||||
return this.isProviderConfigured(provider);
|
||||
}
|
||||
// Return true if at least one provider is configured
|
||||
return this.anthropicClient !== null || this.openaiClient !== null;
|
||||
// Configuration is checked per-request with user API keys
|
||||
// This method is kept for backward compatibility
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the prompt that would be sent to the AI for a given application
|
||||
@@ -1011,14 +1028,30 @@ class AIService {
|
||||
application: ApplicationDetails,
|
||||
userMessage: string,
|
||||
conversationId?: string,
|
||||
provider: AIProvider = config.defaultAIProvider
|
||||
provider: AIProvider = 'claude', // Default to 'claude', but should be provided from user settings
|
||||
userApiKeys?: { anthropic?: string; openai?: string; tavily?: string }
|
||||
): Promise<ChatResponse> {
|
||||
// Validate provider
|
||||
if (provider === 'claude' && !this.anthropicClient) {
|
||||
throw new Error('Claude API not configured. Please set ANTHROPIC_API_KEY.');
|
||||
// API keys must be provided via userApiKeys - they're configured in user profile settings
|
||||
const anthropicKey = userApiKeys?.anthropic;
|
||||
const openaiKey = userApiKeys?.openai;
|
||||
|
||||
// Create clients with user keys
|
||||
let anthropicClient: Anthropic | null = null;
|
||||
let openaiClient: OpenAI | null = null;
|
||||
|
||||
if (anthropicKey) {
|
||||
anthropicClient = new Anthropic({ apiKey: anthropicKey });
|
||||
}
|
||||
if (provider === 'openai' && !this.openaiClient) {
|
||||
throw new Error('OpenAI API not configured. Please set OPENAI_API_KEY.');
|
||||
if (openaiKey) {
|
||||
openaiClient = new OpenAI({ apiKey: openaiKey });
|
||||
}
|
||||
|
||||
// Validate provider - API keys must be provided via userApiKeys
|
||||
if (provider === 'claude' && !anthropicKey) {
|
||||
throw new Error('Claude API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
if (provider === 'openai' && !openaiKey) {
|
||||
throw new Error('OpenAI API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
|
||||
// Get or create conversation
|
||||
@@ -1062,7 +1095,11 @@ class AIService {
|
||||
const systemMessage = aiMessages.find(m => m.role === 'system');
|
||||
const otherMessages = aiMessages.filter(m => m.role !== 'system');
|
||||
|
||||
const response = await this.anthropicClient!.messages.create({
|
||||
if (!anthropicClient) {
|
||||
throw new Error('Claude API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
|
||||
const response = await anthropicClient.messages.create({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 4096,
|
||||
system: systemMessage?.content || '',
|
||||
@@ -1075,7 +1112,11 @@ class AIService {
|
||||
assistantContent = response.content[0].type === 'text' ? response.content[0].text : '';
|
||||
} else {
|
||||
// OpenAI
|
||||
const response = await this.openaiClient!.chat.completions.create({
|
||||
if (!openaiClient) {
|
||||
throw new Error('OpenAI API not configured. Please configure the API key in your user settings.');
|
||||
}
|
||||
|
||||
const response = await openaiClient.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
max_tokens: 4096,
|
||||
messages: aiMessages.map(m => ({
|
||||
|
||||
@@ -79,6 +79,13 @@ class CMDBService {
|
||||
): Promise<T | null> {
|
||||
// Force refresh: search Jira by key
|
||||
if (options?.forceRefresh) {
|
||||
// Check if Jira token is configured before making API call
|
||||
if (!jiraAssetsClient.hasToken()) {
|
||||
logger.debug(`CMDBService: Jira PAT not configured, cannot search for ${typeName} with key ${objectKey}`);
|
||||
// Return cached version if available
|
||||
return await cacheStore.getObjectByKey<T>(typeName, objectKey) || null;
|
||||
}
|
||||
|
||||
const typeDef = OBJECT_TYPES[typeName];
|
||||
if (!typeDef) return null;
|
||||
|
||||
@@ -235,7 +242,15 @@ class CMDBService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// 3. Send update to Jira
|
||||
// 3. Check if user PAT is configured before sending update (write operations require user PAT)
|
||||
if (!jiraAssetsClient.hasUserToken()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.',
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Send update to Jira
|
||||
const success = await jiraAssetsClient.updateObject(id, payload);
|
||||
|
||||
if (!success) {
|
||||
@@ -271,6 +286,14 @@ class CMDBService {
|
||||
id: string,
|
||||
updates: Record<string, unknown>
|
||||
): Promise<UpdateResult> {
|
||||
// Check if user PAT is configured before sending update (write operations require user PAT)
|
||||
if (!jiraAssetsClient.hasUserToken()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.buildUpdatePayload(typeName, updates);
|
||||
|
||||
|
||||
@@ -48,7 +48,9 @@ import { calculateRequiredEffortWithMinMax } from './effortCalculation.js';
|
||||
import { calculateApplicationCompleteness } from './dataCompletenessConfig.js';
|
||||
|
||||
// Determine if we should use real Jira Assets or mock data
|
||||
const useJiraAssets = !!(config.jiraPat && config.jiraSchemaId);
|
||||
// Jira PAT is now configured per-user, so we check if schema is configured
|
||||
// The actual PAT is provided per-request via middleware
|
||||
const useJiraAssets = !!config.jiraSchemaId;
|
||||
|
||||
if (useJiraAssets) {
|
||||
logger.info('DataService: Using CMDB cache layer with Jira Assets API');
|
||||
@@ -121,9 +123,40 @@ async function lookupReferences<T extends CMDBObject>(
|
||||
|
||||
/**
|
||||
* Convert ObjectReference to ReferenceValue format used by frontend
|
||||
* Try to enrich with description from jiraAssetsService cache if available
|
||||
* If not in cache or cache entry has no description, fetch it async
|
||||
*/
|
||||
function toReferenceValue(ref: ObjectReference | null | undefined): ReferenceValue | null {
|
||||
async function toReferenceValue(ref: ObjectReference | null | undefined): Promise<ReferenceValue | null> {
|
||||
if (!ref) return null;
|
||||
|
||||
// Try to get enriched ReferenceValue from jiraAssetsService cache (includes description if available)
|
||||
const enriched = useJiraAssets ? jiraAssetsService.getEnrichedReferenceValue(ref.objectKey, ref.objectId) : null;
|
||||
|
||||
if (enriched && enriched.description) {
|
||||
// Use enriched value with description
|
||||
return enriched;
|
||||
}
|
||||
|
||||
// Cache miss or no description - fetch it async if using Jira Assets
|
||||
if (useJiraAssets && enriched && !enriched.description) {
|
||||
// We have a cached value but it lacks description - fetch it
|
||||
const fetched = await jiraAssetsService.fetchEnrichedReferenceValue(ref.objectKey, ref.objectId);
|
||||
if (fetched) {
|
||||
return fetched;
|
||||
}
|
||||
// If fetch failed, return the cached value anyway
|
||||
return enriched;
|
||||
}
|
||||
|
||||
if (useJiraAssets) {
|
||||
// Cache miss - fetch it
|
||||
const fetched = await jiraAssetsService.fetchEnrichedReferenceValue(ref.objectKey, ref.objectId);
|
||||
if (fetched) {
|
||||
return fetched;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to basic conversion without description (if fetch failed or not using Jira Assets)
|
||||
return {
|
||||
objectId: ref.objectId,
|
||||
key: ref.objectKey,
|
||||
@@ -188,25 +221,49 @@ function extractDisplayValue(value: unknown): string | null {
|
||||
* References are now stored as ObjectReference objects directly (not IDs)
|
||||
*/
|
||||
async function toApplicationDetails(app: ApplicationComponent): Promise<ApplicationDetails> {
|
||||
// Debug logging for confluenceSpace from cache
|
||||
logger.info(`[toApplicationDetails] Converting cached object ${app.objectKey || app.id} to ApplicationDetails`);
|
||||
logger.info(`[toApplicationDetails] confluenceSpace from cache: ${app.confluenceSpace} (type: ${typeof app.confluenceSpace})`);
|
||||
|
||||
// Handle confluenceSpace - it can be a string (URL) or number (legacy), convert to string
|
||||
const confluenceSpaceValue = app.confluenceSpace !== null && app.confluenceSpace !== undefined
|
||||
? (typeof app.confluenceSpace === 'string' ? app.confluenceSpace : String(app.confluenceSpace))
|
||||
: null;
|
||||
|
||||
// Ensure factor caches are loaded for factor value lookup
|
||||
await ensureFactorCaches();
|
||||
|
||||
// Convert ObjectReference to ReferenceValue format
|
||||
const governanceModel = toReferenceValue(app.ictGovernanceModel);
|
||||
// Note: applicationManagementSubteam and applicationManagementTeam are not in the generated schema
|
||||
// They are only available when fetching directly from Jira API (via jiraAssetsClient)
|
||||
const applicationSubteam = toReferenceValue((app as any).applicationManagementSubteam);
|
||||
const applicationTeam = toReferenceValue((app as any).applicationManagementTeam);
|
||||
const applicationType = toReferenceValue(app.applicationManagementApplicationType);
|
||||
const applicationManagementHosting = toReferenceValue(app.applicationManagementHosting);
|
||||
const applicationManagementTAM = toReferenceValue(app.applicationManagementTAM);
|
||||
const hostingType = toReferenceValue(app.applicationComponentHostingType);
|
||||
const businessImpactAnalyse = toReferenceValue(app.businessImpactAnalyse);
|
||||
const platform = toReferenceValue(app.platform);
|
||||
const organisation = toReferenceValue(app.organisation);
|
||||
const businessImportance = toReferenceValue(app.businessImportance);
|
||||
// Fetch descriptions async if not in cache
|
||||
// Use Promise.all to fetch all reference values in parallel for better performance
|
||||
const [
|
||||
governanceModel,
|
||||
applicationSubteam,
|
||||
applicationTeam,
|
||||
applicationType,
|
||||
applicationManagementHosting,
|
||||
applicationManagementTAM,
|
||||
hostingType,
|
||||
businessImpactAnalyse,
|
||||
platform,
|
||||
organisation,
|
||||
businessImportance,
|
||||
] = await Promise.all([
|
||||
toReferenceValue(app.ictGovernanceModel),
|
||||
toReferenceValue((app as any).applicationManagementSubteam),
|
||||
toReferenceValue((app as any).applicationManagementTeam),
|
||||
toReferenceValue(app.applicationManagementApplicationType),
|
||||
toReferenceValue(app.applicationManagementHosting),
|
||||
toReferenceValue(app.applicationManagementTAM),
|
||||
toReferenceValue(app.applicationComponentHostingType),
|
||||
toReferenceValue(app.businessImpactAnalyse),
|
||||
toReferenceValue(app.platform),
|
||||
toReferenceValue(app.organisation),
|
||||
toReferenceValue(app.businessImportance),
|
||||
]);
|
||||
|
||||
// Look up factor values from cached factor objects (same as toMinimalDetailsForEffort)
|
||||
// Also include descriptions from cache if available
|
||||
let dynamicsFactor: ReferenceValue | null = null;
|
||||
if (app.applicationManagementDynamicsFactor && typeof app.applicationManagementDynamicsFactor === 'object') {
|
||||
const factorObj = dynamicsFactorCache?.get(app.applicationManagementDynamicsFactor.objectId);
|
||||
@@ -215,6 +272,7 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
|
||||
key: app.applicationManagementDynamicsFactor.objectKey,
|
||||
name: app.applicationManagementDynamicsFactor.label,
|
||||
factor: factorObj?.factor ?? undefined,
|
||||
description: factorObj?.description ?? undefined, // Include description from cache
|
||||
};
|
||||
}
|
||||
|
||||
@@ -226,6 +284,7 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
|
||||
key: app.applicationManagementComplexityFactor.objectKey,
|
||||
name: app.applicationManagementComplexityFactor.label,
|
||||
factor: factorObj?.factor ?? undefined,
|
||||
description: factorObj?.description ?? undefined, // Include description from cache
|
||||
};
|
||||
}
|
||||
|
||||
@@ -237,6 +296,7 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
|
||||
key: app.applicationManagementNumberOfUsers.objectKey,
|
||||
name: app.applicationManagementNumberOfUsers.label,
|
||||
factor: factorObj?.factor ?? undefined,
|
||||
description: (factorObj as any)?.description ?? undefined, // Include description from cache (may not be in generated types)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -286,6 +346,12 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
|
||||
// Override
|
||||
overrideFTE: app.applicationManagementOverrideFTE ?? null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
|
||||
// Enterprise Architect reference
|
||||
reference: app.reference || null,
|
||||
|
||||
// Confluence Space (URL string)
|
||||
confluenceSpace: confluenceSpaceValue,
|
||||
};
|
||||
|
||||
// Calculate data completeness percentage
|
||||
@@ -356,6 +422,12 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
|
||||
overrideFTE: app.applicationManagementOverrideFTE ?? null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
dataCompletenessPercentage: Math.round(completenessPercentage * 10) / 10, // Round to 1 decimal
|
||||
|
||||
// Enterprise Architect reference
|
||||
reference: app.reference || null,
|
||||
|
||||
// Confluence Space (URL string)
|
||||
confluenceSpace: confluenceSpaceValue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -397,11 +469,18 @@ function clearFactorCaches(): void {
|
||||
* This avoids the overhead of toApplicationDetails while providing enough data for effort calculation
|
||||
* Note: ensureFactorCaches() must be called before using this function
|
||||
*/
|
||||
function toMinimalDetailsForEffort(app: ApplicationComponent): ApplicationDetails {
|
||||
const governanceModel = toReferenceValue(app.ictGovernanceModel);
|
||||
const applicationType = toReferenceValue(app.applicationManagementApplicationType);
|
||||
const businessImpactAnalyse = toReferenceValue(app.businessImpactAnalyse);
|
||||
const applicationManagementHosting = toReferenceValue(app.applicationManagementHosting);
|
||||
async function toMinimalDetailsForEffort(app: ApplicationComponent): Promise<ApplicationDetails> {
|
||||
const [
|
||||
governanceModel,
|
||||
applicationType,
|
||||
businessImpactAnalyse,
|
||||
applicationManagementHosting,
|
||||
] = await Promise.all([
|
||||
toReferenceValue(app.ictGovernanceModel),
|
||||
toReferenceValue(app.applicationManagementApplicationType),
|
||||
toReferenceValue(app.businessImpactAnalyse),
|
||||
toReferenceValue(app.applicationManagementHosting),
|
||||
]);
|
||||
|
||||
// Look up factor values from cached factor objects
|
||||
let dynamicsFactor: ReferenceValue | null = null;
|
||||
@@ -434,6 +513,7 @@ function toMinimalDetailsForEffort(app: ApplicationComponent): ApplicationDetail
|
||||
key: app.applicationManagementNumberOfUsers.objectKey,
|
||||
name: app.applicationManagementNumberOfUsers.label,
|
||||
factor: factorObj?.factor ?? undefined,
|
||||
description: (factorObj as any)?.description ?? undefined, // Include description from cache (may not be in generated types)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -474,23 +554,38 @@ function toMinimalDetailsForEffort(app: ApplicationComponent): ApplicationDetail
|
||||
/**
|
||||
* Convert ApplicationComponent to ApplicationListItem (lighter weight, for lists)
|
||||
*/
|
||||
function toApplicationListItem(app: ApplicationComponent): ApplicationListItem {
|
||||
async function toApplicationListItem(app: ApplicationComponent): Promise<ApplicationListItem> {
|
||||
// Use direct ObjectReference conversion instead of lookups
|
||||
const governanceModel = toReferenceValue(app.ictGovernanceModel);
|
||||
const dynamicsFactor = toReferenceValue(app.applicationManagementDynamicsFactor);
|
||||
const complexityFactor = toReferenceValue(app.applicationManagementComplexityFactor);
|
||||
// Note: Team/Subteam fields are not in generated schema, use type assertion
|
||||
const applicationSubteam = toReferenceValue((app as any).applicationManagementSubteam);
|
||||
const applicationTeam = toReferenceValue((app as any).applicationManagementTeam);
|
||||
const applicationType = toReferenceValue(app.applicationManagementApplicationType);
|
||||
const platform = toReferenceValue(app.platform);
|
||||
// Fetch all reference values in parallel
|
||||
const [
|
||||
governanceModel,
|
||||
dynamicsFactor,
|
||||
complexityFactor,
|
||||
applicationSubteam,
|
||||
applicationTeam,
|
||||
applicationType,
|
||||
platform,
|
||||
applicationManagementHosting,
|
||||
applicationManagementTAM,
|
||||
businessImpactAnalyse,
|
||||
minimalDetails,
|
||||
] = await Promise.all([
|
||||
toReferenceValue(app.ictGovernanceModel),
|
||||
toReferenceValue(app.applicationManagementDynamicsFactor),
|
||||
toReferenceValue(app.applicationManagementComplexityFactor),
|
||||
toReferenceValue((app as any).applicationManagementSubteam),
|
||||
toReferenceValue((app as any).applicationManagementTeam),
|
||||
toReferenceValue(app.applicationManagementApplicationType),
|
||||
toReferenceValue(app.platform),
|
||||
toReferenceValue(app.applicationManagementHosting),
|
||||
toReferenceValue(app.applicationManagementTAM),
|
||||
toReferenceValue(app.businessImpactAnalyse),
|
||||
toMinimalDetailsForEffort(app),
|
||||
]);
|
||||
|
||||
const applicationFunctions = toReferenceValues(app.applicationFunction);
|
||||
const applicationManagementHosting = toReferenceValue(app.applicationManagementHosting);
|
||||
const applicationManagementTAM = toReferenceValue(app.applicationManagementTAM);
|
||||
const businessImpactAnalyse = toReferenceValue(app.businessImpactAnalyse);
|
||||
|
||||
// Calculate effort using minimal details
|
||||
const minimalDetails = toMinimalDetailsForEffort(app);
|
||||
const effortResult = calculateRequiredEffortWithMinMax(minimalDetails);
|
||||
|
||||
const result: ApplicationListItem = {
|
||||
@@ -518,12 +613,17 @@ function toApplicationListItem(app: ApplicationComponent): ApplicationListItem {
|
||||
|
||||
// Calculate data completeness percentage
|
||||
// Convert ApplicationListItem to format expected by completeness calculator
|
||||
const [organisationRef, hostingTypeRef] = await Promise.all([
|
||||
toReferenceValue(app.organisation),
|
||||
toReferenceValue(app.applicationComponentHostingType),
|
||||
]);
|
||||
|
||||
const appForCompleteness = {
|
||||
organisation: toReferenceValue(app.organisation)?.name || null,
|
||||
organisation: organisationRef?.name || null,
|
||||
applicationFunctions: result.applicationFunctions,
|
||||
status: result.status,
|
||||
businessImpactAnalyse: result.businessImpactAnalyse,
|
||||
hostingType: toReferenceValue(app.applicationComponentHostingType),
|
||||
hostingType: hostingTypeRef,
|
||||
supplierProduct: app.supplierProduct?.label || null,
|
||||
businessOwner: app.businessOwner?.label || null,
|
||||
systemOwner: app.systemOwner?.label || null,
|
||||
@@ -535,7 +635,7 @@ function toApplicationListItem(app: ApplicationComponent): ApplicationListItem {
|
||||
applicationManagementTAM: result.applicationManagementTAM,
|
||||
dynamicsFactor: result.dynamicsFactor,
|
||||
complexityFactor: result.complexityFactor,
|
||||
numberOfUsers: toReferenceValue(app.applicationManagementNumberOfUsers),
|
||||
numberOfUsers: await toReferenceValue(app.applicationManagementNumberOfUsers),
|
||||
};
|
||||
|
||||
const completenessPercentage = calculateApplicationCompleteness(appForCompleteness);
|
||||
@@ -718,8 +818,8 @@ export const dataService = {
|
||||
// Ensure factor caches are loaded for effort calculation
|
||||
await ensureFactorCaches();
|
||||
|
||||
// Convert to list items (synchronous now)
|
||||
const applications = paginatedApps.map(toApplicationListItem);
|
||||
// Convert to list items (async now to fetch descriptions)
|
||||
const applications = await Promise.all(paginatedApps.map(toApplicationListItem));
|
||||
|
||||
return {
|
||||
applications,
|
||||
@@ -1221,8 +1321,8 @@ export const dataService = {
|
||||
for (const app of apps) {
|
||||
// Get team from application (via subteam lookup if needed)
|
||||
let team: ReferenceValue | null = null;
|
||||
const applicationSubteam = toReferenceValue((app as any).applicationManagementSubteam);
|
||||
const applicationTeam = toReferenceValue((app as any).applicationManagementTeam);
|
||||
const applicationSubteam = await toReferenceValue((app as any).applicationManagementSubteam);
|
||||
const applicationTeam = await toReferenceValue((app as any).applicationManagementTeam);
|
||||
|
||||
// Prefer direct team assignment, otherwise try to get from subteam
|
||||
if (applicationTeam) {
|
||||
@@ -1265,7 +1365,7 @@ export const dataService = {
|
||||
|
||||
// Get BIA value
|
||||
if (app.businessImpactAnalyse) {
|
||||
const biaRef = toReferenceValue(app.businessImpactAnalyse);
|
||||
const biaRef = await toReferenceValue(app.businessImpactAnalyse);
|
||||
if (biaRef) {
|
||||
const biaNum = biaToNumeric(biaRef.name);
|
||||
if (biaNum !== null) metrics.biaValues.push(biaNum);
|
||||
@@ -1274,7 +1374,7 @@ export const dataService = {
|
||||
|
||||
// Get governance maturity
|
||||
if (app.ictGovernanceModel) {
|
||||
const govRef = toReferenceValue(app.ictGovernanceModel);
|
||||
const govRef = await toReferenceValue(app.ictGovernanceModel);
|
||||
if (govRef) {
|
||||
const maturity = governanceToMaturity(govRef.name);
|
||||
if (maturity !== null) metrics.governanceValues.push(maturity);
|
||||
@@ -1327,6 +1427,10 @@ export const dataService = {
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
if (!useJiraAssets) return true;
|
||||
// Only test connection if token is configured
|
||||
if (!jiraAssetsClient.hasToken()) {
|
||||
return false;
|
||||
}
|
||||
return jiraAssetsClient.testConnection();
|
||||
},
|
||||
|
||||
@@ -1413,7 +1517,7 @@ export const dataService = {
|
||||
if (!app.id || !app.label) continue;
|
||||
|
||||
// Extract Business Importance from app object
|
||||
const businessImportanceRef = toReferenceValue(app.businessImportance);
|
||||
const businessImportanceRef = await toReferenceValue(app.businessImportance);
|
||||
const businessImportanceName = businessImportanceRef?.name || null;
|
||||
|
||||
// Normalize Business Importance
|
||||
@@ -1436,7 +1540,7 @@ export const dataService = {
|
||||
}
|
||||
|
||||
// Extract BIA from app object
|
||||
const businessImpactAnalyseRef = toReferenceValue(app.businessImpactAnalyse);
|
||||
const businessImpactAnalyseRef = await toReferenceValue(app.businessImpactAnalyse);
|
||||
|
||||
// Normalize BIA Class
|
||||
let biaClass: string | null = null;
|
||||
|
||||
@@ -16,8 +16,9 @@ const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* Create a database adapter based on environment variables
|
||||
* @param allowClose - If false, the adapter won't be closed when close() is called (for singletons)
|
||||
*/
|
||||
export function createDatabaseAdapter(dbType?: string, dbPath?: string): DatabaseAdapter {
|
||||
export function createDatabaseAdapter(dbType?: string, dbPath?: string, allowClose: boolean = true): DatabaseAdapter {
|
||||
const type = dbType || process.env.DATABASE_TYPE || 'sqlite';
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
@@ -33,11 +34,11 @@ export function createDatabaseAdapter(dbType?: string, dbPath?: string): Databas
|
||||
|
||||
const constructedUrl = `postgresql://${user}:${password}@${host}:${port}/${name}${ssl}`;
|
||||
logger.info('Creating PostgreSQL adapter with constructed connection string');
|
||||
return new PostgresAdapter(constructedUrl);
|
||||
return new PostgresAdapter(constructedUrl, allowClose);
|
||||
}
|
||||
|
||||
logger.info('Creating PostgreSQL adapter');
|
||||
return new PostgresAdapter(databaseUrl);
|
||||
return new PostgresAdapter(databaseUrl, allowClose);
|
||||
}
|
||||
|
||||
// Default to SQLite
|
||||
|
||||
532
backend/src/services/database/migrations.ts
Normal file
532
backend/src/services/database/migrations.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
/**
|
||||
* Database Migrations
|
||||
*
|
||||
* Handles database schema creation and migrations for authentication and authorization system.
|
||||
*/
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
import type { DatabaseAdapter } from './interface.js';
|
||||
import { createDatabaseAdapter } from './factory.js';
|
||||
// @ts-ignore - bcrypt doesn't have proper ESM types
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
export interface Migration {
|
||||
name: string;
|
||||
up: (db: DatabaseAdapter) => Promise<void>;
|
||||
down?: (db: DatabaseAdapter) => Promise<void>;
|
||||
}
|
||||
|
||||
const isPostgres = (): boolean => {
|
||||
return process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql';
|
||||
};
|
||||
|
||||
const getTimestamp = (): string => {
|
||||
return new Date().toISOString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Create users table
|
||||
*/
|
||||
async function createUsersTable(db: DatabaseAdapter): Promise<void> {
|
||||
const isPg = isPostgres();
|
||||
|
||||
const schema = isPg ? `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
email_verified BOOLEAN DEFAULT false,
|
||||
email_verification_token TEXT,
|
||||
password_reset_token TEXT,
|
||||
password_reset_expires TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_login TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email_verification_token ON users(email_verification_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_password_reset_token ON users(password_reset_token);
|
||||
` : `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
email_verification_token TEXT,
|
||||
password_reset_token TEXT,
|
||||
password_reset_expires TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_login TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email_verification_token ON users(email_verification_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_password_reset_token ON users(password_reset_token);
|
||||
`;
|
||||
|
||||
await db.exec(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create roles table
|
||||
*/
|
||||
async function createRolesTable(db: DatabaseAdapter): Promise<void> {
|
||||
const isPg = isPostgres();
|
||||
|
||||
const schema = isPg ? `
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
is_system_role BOOLEAN DEFAULT false,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_roles_name ON roles(name);
|
||||
` : `
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
is_system_role INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_roles_name ON roles(name);
|
||||
`;
|
||||
|
||||
await db.exec(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create permissions table
|
||||
*/
|
||||
async function createPermissionsTable(db: DatabaseAdapter): Promise<void> {
|
||||
const isPg = isPostgres();
|
||||
|
||||
const schema = isPg ? `
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
resource TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_permissions_name ON permissions(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_permissions_resource ON permissions(resource);
|
||||
` : `
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
resource TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_permissions_name ON permissions(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_permissions_resource ON permissions(resource);
|
||||
`;
|
||||
|
||||
await db.exec(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create role_permissions junction table
|
||||
*/
|
||||
async function createRolePermissionsTable(db: DatabaseAdapter): Promise<void> {
|
||||
const isPg = isPostgres();
|
||||
|
||||
const schema = isPg ? `
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
permission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (role_id, permission_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON role_permissions(permission_id);
|
||||
` : `
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
permission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (role_id, permission_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON role_permissions(permission_id);
|
||||
`;
|
||||
|
||||
await db.exec(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user_roles junction table
|
||||
*/
|
||||
async function createUserRolesTable(db: DatabaseAdapter): Promise<void> {
|
||||
const isPg = isPostgres();
|
||||
|
||||
const schema = isPg ? `
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
assigned_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id);
|
||||
` : `
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
assigned_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id);
|
||||
`;
|
||||
|
||||
await db.exec(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user_settings table
|
||||
*/
|
||||
async function createUserSettingsTable(db: DatabaseAdapter): Promise<void> {
|
||||
const isPg = isPostgres();
|
||||
|
||||
const schema = isPg ? `
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
jira_pat TEXT,
|
||||
jira_pat_encrypted BOOLEAN DEFAULT true,
|
||||
ai_enabled BOOLEAN DEFAULT false,
|
||||
ai_provider TEXT,
|
||||
ai_api_key TEXT,
|
||||
web_search_enabled BOOLEAN DEFAULT false,
|
||||
tavily_api_key TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
` : `
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
jira_pat TEXT,
|
||||
jira_pat_encrypted INTEGER DEFAULT 1,
|
||||
ai_enabled INTEGER DEFAULT 0,
|
||||
ai_provider TEXT,
|
||||
ai_api_key TEXT,
|
||||
web_search_enabled INTEGER DEFAULT 0,
|
||||
tavily_api_key TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
await db.exec(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sessions table
|
||||
*/
|
||||
async function createSessionsTable(db: DatabaseAdapter): Promise<void> {
|
||||
const isPg = isPostgres();
|
||||
|
||||
const schema = isPg ? `
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
auth_method TEXT NOT NULL,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_auth_method ON sessions(auth_method);
|
||||
` : `
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
auth_method TEXT NOT NULL,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_auth_method ON sessions(auth_method);
|
||||
`;
|
||||
|
||||
await db.exec(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create email_tokens table
|
||||
*/
|
||||
async function createEmailTokensTable(db: DatabaseAdapter): Promise<void> {
|
||||
const isPg = isPostgres();
|
||||
|
||||
const schema = isPg ? `
|
||||
CREATE TABLE IF NOT EXISTS email_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used BOOLEAN DEFAULT false,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_tokens_user_id ON email_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_tokens_type ON email_tokens(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_tokens_expires_at ON email_tokens(expires_at);
|
||||
` : `
|
||||
CREATE TABLE IF NOT EXISTS email_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_tokens_user_id ON email_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_tokens_type ON email_tokens(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_tokens_expires_at ON email_tokens(expires_at);
|
||||
`;
|
||||
|
||||
await db.exec(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed initial data
|
||||
*/
|
||||
async function seedInitialData(db: DatabaseAdapter): Promise<void> {
|
||||
const isPg = isPostgres();
|
||||
const now = getTimestamp();
|
||||
|
||||
// Check if roles already exist
|
||||
const existingRoles = await db.query('SELECT COUNT(*) as count FROM roles');
|
||||
const roleCount = isPg ? (existingRoles[0] as any).count : (existingRoles[0] as any).count;
|
||||
|
||||
// If roles exist, we still need to check if admin user exists
|
||||
// (roles might exist but admin user might not)
|
||||
const rolesExist = parseInt(roleCount) > 0;
|
||||
|
||||
if (rolesExist) {
|
||||
logger.info('Roles already exist, checking if admin user needs to be created...');
|
||||
}
|
||||
|
||||
// Get existing role IDs if roles already exist
|
||||
const roleIds: Record<string, number> = {};
|
||||
|
||||
if (!rolesExist) {
|
||||
// Insert default permissions
|
||||
const permissions = [
|
||||
{ name: 'search', description: 'Access search features', resource: 'search' },
|
||||
{ name: 'view_reports', description: 'View reports and dashboards', resource: 'reports' },
|
||||
{ name: 'edit_applications', description: 'Edit application components', resource: 'applications' },
|
||||
{ name: 'manage_users', description: 'Manage users and their roles', resource: 'users' },
|
||||
{ name: 'manage_roles', description: 'Manage roles and permissions', resource: 'roles' },
|
||||
{ name: 'manage_settings', description: 'Manage application settings', resource: 'settings' },
|
||||
];
|
||||
|
||||
for (const perm of permissions) {
|
||||
await db.execute(
|
||||
'INSERT INTO permissions (name, description, resource) VALUES (?, ?, ?)',
|
||||
[perm.name, perm.description, perm.resource]
|
||||
);
|
||||
}
|
||||
|
||||
// Insert default roles
|
||||
const roles = [
|
||||
{ name: 'administrator', description: 'Full system access', isSystem: true },
|
||||
{ name: 'user', description: 'Basic user access', isSystem: true },
|
||||
];
|
||||
|
||||
for (const role of roles) {
|
||||
const isSystem = isPg ? role.isSystem : (role.isSystem ? 1 : 0);
|
||||
await db.execute(
|
||||
'INSERT INTO roles (name, description, is_system_role, created_at) VALUES (?, ?, ?, ?)',
|
||||
[role.name, role.description, isSystem, now]
|
||||
);
|
||||
|
||||
// Get the inserted role ID
|
||||
const insertedRole = await db.queryOne<{ id: number }>(
|
||||
'SELECT id FROM roles WHERE name = ?',
|
||||
[role.name]
|
||||
);
|
||||
|
||||
if (insertedRole) {
|
||||
roleIds[role.name] = insertedRole.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Assign all permissions to administrator role
|
||||
const allPermissions = await db.query<{ id: number }>('SELECT id FROM permissions');
|
||||
for (const perm of allPermissions) {
|
||||
await db.execute(
|
||||
'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)',
|
||||
[roleIds['administrator'], perm.id]
|
||||
);
|
||||
}
|
||||
|
||||
// Assign basic permissions to user role (search and view_reports)
|
||||
const searchPerm = await db.queryOne<{ id: number }>(
|
||||
'SELECT id FROM permissions WHERE name = ?',
|
||||
['search']
|
||||
);
|
||||
const viewReportsPerm = await db.queryOne<{ id: number }>(
|
||||
'SELECT id FROM permissions WHERE name = ?',
|
||||
['view_reports']
|
||||
);
|
||||
|
||||
if (searchPerm) {
|
||||
await db.execute(
|
||||
'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)',
|
||||
[roleIds['user'], searchPerm.id]
|
||||
);
|
||||
}
|
||||
if (viewReportsPerm) {
|
||||
await db.execute(
|
||||
'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)',
|
||||
[roleIds['user'], viewReportsPerm.id]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Roles exist - get their IDs
|
||||
const adminRole = await db.queryOne<{ id: number }>(
|
||||
'SELECT id FROM roles WHERE name = ?',
|
||||
['administrator']
|
||||
);
|
||||
if (adminRole) {
|
||||
roleIds['administrator'] = adminRole.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Create initial admin user if ADMIN_EMAIL and ADMIN_PASSWORD are set
|
||||
const adminEmail = process.env.ADMIN_EMAIL;
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
|
||||
|
||||
if (adminEmail && adminPassword) {
|
||||
// Check if admin user already exists
|
||||
const existingUser = await db.queryOne<{ id: number }>(
|
||||
'SELECT id FROM users WHERE email = ? OR username = ?',
|
||||
[adminEmail, adminUsername]
|
||||
);
|
||||
|
||||
if (existingUser) {
|
||||
// User exists - check if they have admin role
|
||||
const hasAdminRole = await db.queryOne<{ role_id: number }>(
|
||||
'SELECT role_id FROM user_roles WHERE user_id = ? AND role_id = ?',
|
||||
[existingUser.id, roleIds['administrator']]
|
||||
);
|
||||
|
||||
if (!hasAdminRole && roleIds['administrator']) {
|
||||
// Add admin role if missing
|
||||
await db.execute(
|
||||
'INSERT INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)',
|
||||
[existingUser.id, roleIds['administrator'], now]
|
||||
);
|
||||
logger.info(`Administrator role assigned to existing user: ${adminEmail}`);
|
||||
} else {
|
||||
logger.info(`Administrator user already exists: ${adminEmail}`);
|
||||
}
|
||||
} else {
|
||||
// Create new admin user
|
||||
const passwordHash = await bcrypt.hash(adminPassword, SALT_ROUNDS);
|
||||
const displayName = process.env.ADMIN_DISPLAY_NAME || 'Administrator';
|
||||
|
||||
await db.execute(
|
||||
'INSERT INTO users (email, username, password_hash, display_name, is_active, email_verified, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[adminEmail, adminUsername, passwordHash, displayName, isPg ? true : 1, isPg ? true : 1, now, now]
|
||||
);
|
||||
|
||||
const adminUser = await db.queryOne<{ id: number }>(
|
||||
'SELECT id FROM users WHERE email = ?',
|
||||
[adminEmail]
|
||||
);
|
||||
|
||||
if (adminUser && roleIds['administrator']) {
|
||||
await db.execute(
|
||||
'INSERT INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)',
|
||||
[adminUser.id, roleIds['administrator'], now]
|
||||
);
|
||||
logger.info(`Initial administrator user created: ${adminEmail}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn('ADMIN_EMAIL and ADMIN_PASSWORD not set - skipping initial admin user creation');
|
||||
}
|
||||
|
||||
logger.info('Initial data seeded successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main migration function
|
||||
*/
|
||||
export async function runMigrations(): Promise<void> {
|
||||
const db = createDatabaseAdapter();
|
||||
|
||||
try {
|
||||
logger.info('Running database migrations...');
|
||||
|
||||
await createUsersTable(db);
|
||||
await createRolesTable(db);
|
||||
await createPermissionsTable(db);
|
||||
await createRolePermissionsTable(db);
|
||||
await createUserRolesTable(db);
|
||||
await createUserSettingsTable(db);
|
||||
await createSessionsTable(db);
|
||||
await createEmailTokensTable(db);
|
||||
|
||||
await seedInitialData(db);
|
||||
|
||||
logger.info('Database migrations completed successfully');
|
||||
} catch (error) {
|
||||
logger.error('Migration failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton cache for auth database adapter
|
||||
let authDatabaseAdapter: DatabaseAdapter | null = null;
|
||||
|
||||
/**
|
||||
* Get database adapter for auth operations
|
||||
* Uses a singleton pattern to avoid creating multiple adapters.
|
||||
* The adapter is configured to not close on close() calls, as it should
|
||||
* remain open for the application lifetime.
|
||||
*/
|
||||
export function getAuthDatabase(): DatabaseAdapter {
|
||||
if (!authDatabaseAdapter) {
|
||||
// Create adapter with allowClose=false so it won't be closed after operations
|
||||
authDatabaseAdapter = createDatabaseAdapter(undefined, undefined, false);
|
||||
}
|
||||
return authDatabaseAdapter;
|
||||
}
|
||||
@@ -11,9 +11,12 @@ import type { DatabaseAdapter } from './interface.js';
|
||||
export class PostgresAdapter implements DatabaseAdapter {
|
||||
private pool: Pool;
|
||||
private connectionString: string;
|
||||
private isClosed: boolean = false;
|
||||
private allowClose: boolean = true; // Set to false for singleton instances
|
||||
|
||||
constructor(connectionString: string) {
|
||||
constructor(connectionString: string, allowClose: boolean = true) {
|
||||
this.connectionString = connectionString;
|
||||
this.allowClose = allowClose;
|
||||
this.pool = new Pool({
|
||||
connectionString,
|
||||
max: 20, // Maximum number of clients in the pool
|
||||
@@ -124,7 +127,23 @@ export class PostgresAdapter implements DatabaseAdapter {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
// Don't close singleton instances - they should remain open for the app lifetime
|
||||
if (!this.allowClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make close() idempotent - safe to call multiple times
|
||||
if (this.isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.pool.end();
|
||||
this.isClosed = true;
|
||||
} catch (error) {
|
||||
// Pool might already be closed, ignore the error
|
||||
this.isClosed = true;
|
||||
}
|
||||
}
|
||||
|
||||
async getSizeBytes(): Promise<number> {
|
||||
|
||||
@@ -18,7 +18,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Path to the configuration file (v25)
|
||||
const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config-v25.json');
|
||||
const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config.json');
|
||||
|
||||
// Cache for loaded configuration
|
||||
let cachedConfigV25: EffortCalculationConfigV25 | null = null;
|
||||
@@ -275,12 +275,6 @@ export function calculateRequiredEffortApplicationManagementV25(
|
||||
breakdown.businessImpactAnalyse = biaClass;
|
||||
breakdown.applicationManagementHosting = applicationManagementHosting;
|
||||
|
||||
logger.debug(`=== Effort Calculation v25 ===`);
|
||||
logger.debug(`Regiemodel: ${regieModelCode} (${governanceModelRaw})`);
|
||||
logger.debug(`Application Type: ${applicationType}`);
|
||||
logger.debug(`BIA: ${biaClass} (${businessImpactAnalyseRaw})`);
|
||||
logger.debug(`Hosting: ${applicationManagementHosting}`);
|
||||
|
||||
// Level 1: Find Regiemodel configuration
|
||||
if (!regieModelCode || !config.regiemodellen[regieModelCode]) {
|
||||
breakdown.errors.push(`Geen configuratie gevonden voor regiemodel: ${governanceModelRaw || 'niet ingesteld'}`);
|
||||
@@ -413,10 +407,6 @@ export function calculateRequiredEffortApplicationManagementV25(
|
||||
breakdown.hoursPerMonth = breakdown.hoursPerYear / 12;
|
||||
breakdown.hoursPerWeek = breakdown.hoursPerYear / NET_WORK_WEEKS;
|
||||
|
||||
logger.debug(`Base FTE: ${breakdown.baseEffort} (${breakdown.baseEffortMin} - ${breakdown.baseEffortMax})`);
|
||||
logger.debug(`Final FTE: ${finalEffort}`);
|
||||
logger.debug(`Hours/year: ${breakdown.hoursPerYear}`);
|
||||
|
||||
return { finalEffort, breakdown };
|
||||
|
||||
} catch (error) {
|
||||
|
||||
289
backend/src/services/emailService.ts
Normal file
289
backend/src/services/emailService.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Email Service
|
||||
*
|
||||
* Handles sending emails using Nodemailer with SMTP configuration.
|
||||
* Used for user invitations, password resets, and email verification.
|
||||
*/
|
||||
|
||||
import nodemailer, { Transporter } from 'nodemailer';
|
||||
import { logger } from './logger.js';
|
||||
import { config } from '../config/env.js';
|
||||
|
||||
interface EmailOptions {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
class EmailService {
|
||||
private transporter: Transporter | null = null;
|
||||
private isConfigured: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize email transporter
|
||||
*/
|
||||
private initialize(): void {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT || '587', 10);
|
||||
const smtpSecure = process.env.SMTP_SECURE === 'true';
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPassword = process.env.SMTP_PASSWORD;
|
||||
const smtpFrom = process.env.SMTP_FROM || smtpUser || 'noreply@example.com';
|
||||
|
||||
if (!smtpHost || !smtpUser || !smtpPassword) {
|
||||
logger.warn('SMTP not configured - email functionality will be disabled');
|
||||
this.isConfigured = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure, // true for 465, false for other ports
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPassword,
|
||||
},
|
||||
});
|
||||
|
||||
this.isConfigured = true;
|
||||
logger.info('Email service configured');
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize email service:', error);
|
||||
this.isConfigured = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email
|
||||
*/
|
||||
async sendEmail(options: EmailOptions): Promise<boolean> {
|
||||
if (!this.isConfigured || !this.transporter) {
|
||||
logger.warn('Email service not configured - email not sent:', options.to);
|
||||
// In development, log the email content
|
||||
if (config.isDevelopment) {
|
||||
logger.info('Email would be sent:', {
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const smtpFrom = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@example.com';
|
||||
|
||||
await this.transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
text: options.text || this.htmlToText(options.html),
|
||||
});
|
||||
|
||||
logger.info(`Email sent successfully to ${options.to}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to send email:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send invitation email
|
||||
*/
|
||||
async sendInvitationEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
displayName?: string
|
||||
): Promise<boolean> {
|
||||
const frontendUrl = config.frontendUrl;
|
||||
const invitationUrl = `${frontendUrl}/accept-invitation?token=${token}`;
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background-color: #2563eb; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; background-color: #f9fafb; }
|
||||
.button { display: inline-block; padding: 12px 24px; background-color: #2563eb; color: white; text-decoration: none; border-radius: 4px; margin: 20px 0; }
|
||||
.footer { text-align: center; padding: 20px; color: #6b7280; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Welkom bij CMDB Editor</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Beste ${displayName || 'gebruiker'},</p>
|
||||
<p>Je bent uitgenodigd om een account aan te maken voor de CMDB Editor applicatie.</p>
|
||||
<p>Klik op de onderstaande knop om je account te activeren en een wachtwoord in te stellen:</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="${invitationUrl}" class="button">Account activeren</a>
|
||||
</p>
|
||||
<p>Of kopieer en plak deze link in je browser:</p>
|
||||
<p style="word-break: break-all; color: #2563eb;">${invitationUrl}</p>
|
||||
<p><strong>Deze link is 7 dagen geldig.</strong></p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Zuyderland Medisch Centrum - CMDB Editor</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return this.sendEmail({
|
||||
to: email,
|
||||
subject: 'Uitnodiging voor CMDB Editor',
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async sendPasswordResetEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
displayName?: string
|
||||
): Promise<boolean> {
|
||||
const frontendUrl = config.frontendUrl;
|
||||
const resetUrl = `${frontendUrl}/reset-password?token=${token}`;
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background-color: #dc2626; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; background-color: #f9fafb; }
|
||||
.button { display: inline-block; padding: 12px 24px; background-color: #dc2626; color: white; text-decoration: none; border-radius: 4px; margin: 20px 0; }
|
||||
.footer { text-align: center; padding: 20px; color: #6b7280; font-size: 12px; }
|
||||
.warning { background-color: #fef2f2; border-left: 4px solid #dc2626; padding: 12px; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Wachtwoord resetten</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Beste ${displayName || 'gebruiker'},</p>
|
||||
<p>Je hebt een verzoek gedaan om je wachtwoord te resetten.</p>
|
||||
<p>Klik op de onderstaande knop om een nieuw wachtwoord in te stellen:</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="${resetUrl}" class="button">Wachtwoord resetten</a>
|
||||
</p>
|
||||
<p>Of kopieer en plak deze link in je browser:</p>
|
||||
<p style="word-break: break-all; color: #2563eb;">${resetUrl}</p>
|
||||
<div class="warning">
|
||||
<p><strong>Let op:</strong> Als je dit verzoek niet hebt gedaan, negeer dan deze email. Je wachtwoord blijft ongewijzigd.</p>
|
||||
</div>
|
||||
<p><strong>Deze link is 1 uur geldig.</strong></p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Zuyderland Medisch Centrum - CMDB Editor</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return this.sendEmail({
|
||||
to: email,
|
||||
subject: 'Wachtwoord resetten - CMDB Editor',
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email verification email
|
||||
*/
|
||||
async sendEmailVerificationEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
displayName?: string
|
||||
): Promise<boolean> {
|
||||
const frontendUrl = config.frontendUrl;
|
||||
const verifyUrl = `${frontendUrl}/verify-email?token=${token}`;
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background-color: #059669; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; background-color: #f9fafb; }
|
||||
.button { display: inline-block; padding: 12px 24px; background-color: #059669; color: white; text-decoration: none; border-radius: 4px; margin: 20px 0; }
|
||||
.footer { text-align: center; padding: 20px; color: #6b7280; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>E-mailadres verifiëren</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Beste ${displayName || 'gebruiker'},</p>
|
||||
<p>Bedankt voor het aanmaken van je account. Verifieer je e-mailadres door op de onderstaande knop te klikken:</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="${verifyUrl}" class="button">E-mailadres verifiëren</a>
|
||||
</p>
|
||||
<p>Of kopieer en plak deze link in je browser:</p>
|
||||
<p style="word-break: break-all; color: #2563eb;">${verifyUrl}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Zuyderland Medisch Centrum - CMDB Editor</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return this.sendEmail({
|
||||
to: email,
|
||||
subject: 'E-mailadres verifiëren - CMDB Editor',
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HTML to plain text (simple implementation)
|
||||
*/
|
||||
private htmlToText(html: string): string {
|
||||
return html
|
||||
.replace(/<style[^>]*>.*?<\/style>/gis, '')
|
||||
.replace(/<script[^>]*>.*?<\/script>/gis, '')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email service is configured
|
||||
*/
|
||||
isConfigured(): boolean {
|
||||
return this.isConfigured;
|
||||
}
|
||||
}
|
||||
|
||||
export const emailService = new EmailService();
|
||||
115
backend/src/services/encryptionService.ts
Normal file
115
backend/src/services/encryptionService.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Encryption Service
|
||||
*
|
||||
* Provides encryption/decryption for sensitive data at rest (Jira PATs, API keys).
|
||||
* Uses AES-256-GCM for authenticated encryption.
|
||||
*/
|
||||
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'crypto';
|
||||
import { promisify } from 'util';
|
||||
import { logger } from './logger.js';
|
||||
import { config } from '../config/env.js';
|
||||
|
||||
const scryptAsync = promisify(scrypt);
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY_LENGTH = 32; // 32 bytes for AES-256
|
||||
const IV_LENGTH = 16; // 16 bytes for GCM
|
||||
const SALT_LENGTH = 16; // 16 bytes for salt
|
||||
const TAG_LENGTH = 16; // 16 bytes for authentication tag
|
||||
|
||||
class EncryptionService {
|
||||
private encryptionKey: Buffer | null = null;
|
||||
|
||||
/**
|
||||
* Get or derive encryption key from environment variable
|
||||
*/
|
||||
private async getEncryptionKey(): Promise<Buffer> {
|
||||
if (this.encryptionKey) {
|
||||
return this.encryptionKey;
|
||||
}
|
||||
|
||||
const envKey = process.env.ENCRYPTION_KEY;
|
||||
if (!envKey) {
|
||||
throw new Error('ENCRYPTION_KEY environment variable is required for encryption');
|
||||
}
|
||||
|
||||
// If key is base64 encoded, decode it
|
||||
let key: Buffer;
|
||||
try {
|
||||
key = Buffer.from(envKey, 'base64');
|
||||
if (key.length !== KEY_LENGTH) {
|
||||
throw new Error('Invalid key length');
|
||||
}
|
||||
} catch (error) {
|
||||
// If not base64, derive key from string using scrypt
|
||||
const salt = Buffer.from(envKey.substring(0, SALT_LENGTH), 'utf8');
|
||||
key = (await scryptAsync(envKey, salt, KEY_LENGTH)) as Buffer;
|
||||
}
|
||||
|
||||
this.encryptionKey = key;
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a string value
|
||||
*/
|
||||
async encrypt(plaintext: string): Promise<string> {
|
||||
try {
|
||||
const key = await this.getEncryptionKey();
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
|
||||
encrypted += cipher.final('base64');
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Combine IV + authTag + encrypted data
|
||||
const combined = Buffer.concat([
|
||||
iv,
|
||||
authTag,
|
||||
Buffer.from(encrypted, 'base64')
|
||||
]);
|
||||
|
||||
return combined.toString('base64');
|
||||
} catch (error) {
|
||||
logger.error('Encryption error:', error);
|
||||
throw new Error('Failed to encrypt data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a string value
|
||||
*/
|
||||
async decrypt(encryptedData: string): Promise<string> {
|
||||
try {
|
||||
const key = await this.getEncryptionKey();
|
||||
const combined = Buffer.from(encryptedData, 'base64');
|
||||
|
||||
// Extract IV, authTag, and encrypted data
|
||||
const iv = combined.subarray(0, IV_LENGTH);
|
||||
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||
const encrypted = combined.subarray(IV_LENGTH + TAG_LENGTH);
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, undefined, 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
logger.error('Decryption error:', error);
|
||||
throw new Error('Failed to decrypt data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encryption is properly configured
|
||||
*/
|
||||
isConfigured(): boolean {
|
||||
return !!process.env.ENCRYPTION_KEY;
|
||||
}
|
||||
}
|
||||
|
||||
export const encryptionService = new EncryptionService();
|
||||
@@ -50,6 +50,11 @@ const ATTRIBUTE_NAMES = {
|
||||
APPLICATION_MANAGEMENT_HOSTING: 'Application Management - Hosting',
|
||||
APPLICATION_MANAGEMENT_TAM: 'Application Management - TAM',
|
||||
TECHNISCHE_ARCHITECTUUR: 'Technische Architectuur (TA)',
|
||||
REFERENCE: 'Reference',
|
||||
CONFLUENCE_SPACE: 'Confluence Space',
|
||||
SUPPLIER_TECHNICAL: 'Supplier Technical',
|
||||
SUPPLIER_IMPLEMENTATION: 'Supplier Implementation',
|
||||
SUPPLIER_CONSULTANCY: 'Supplier Consultancy',
|
||||
};
|
||||
|
||||
// Jira Data Center (Insight) uses different API endpoints than Jira Cloud (Assets)
|
||||
@@ -99,6 +104,8 @@ class JiraAssetsService {
|
||||
private numberOfUsersCache: Map<string, ReferenceValue> | null = null;
|
||||
// Cache: Reference objects fetched via fallback (key: objectKey -> ReferenceValue)
|
||||
private referenceObjectCache: Map<string, ReferenceValue> = new Map();
|
||||
// Pending requests cache: prevents duplicate API calls for the same object (key: objectId -> Promise<ReferenceValue>)
|
||||
private pendingReferenceRequests: Map<string, Promise<ReferenceValue | null>> = new Map();
|
||||
// Cache: Team dashboard data
|
||||
private teamDashboardCache: { data: TeamDashboardData; timestamp: number } | null = null;
|
||||
private readonly TEAM_DASHBOARD_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -119,8 +126,8 @@ class JiraAssetsService {
|
||||
// Try both API paths - Insight (Data Center) and Assets (Cloud)
|
||||
this.insightBaseUrl = `${config.jiraHost}/rest/insight/1.0`;
|
||||
this.assetsBaseUrl = `${config.jiraHost}/rest/assets/1.0`;
|
||||
// Jira PAT is now configured per-user - default headers will use request token
|
||||
this.defaultHeaders = {
|
||||
Authorization: `Bearer ${config.jiraPat}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
@@ -136,9 +143,14 @@ class JiraAssetsService {
|
||||
this.requestToken = null;
|
||||
}
|
||||
|
||||
// Get headers with the appropriate token (user token takes precedence)
|
||||
// Get headers with the appropriate token (user token from middleware, fallback to service account)
|
||||
private get headers(): Record<string, string> {
|
||||
const token = this.requestToken || config.jiraPat;
|
||||
// Token must be provided via setRequestToken() from middleware
|
||||
// It comes from user's profile settings, OAuth session, or service account token (fallback)
|
||||
const token = this.requestToken || config.jiraServiceAccountToken;
|
||||
if (!token) {
|
||||
throw new Error('Jira PAT not configured. Please configure it in your user settings or set JIRA_SERVICE_ACCOUNT_TOKEN in .env.');
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
@@ -153,12 +165,15 @@ class JiraAssetsService {
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
options: RequestInit = {},
|
||||
retryCount: number = 0
|
||||
): Promise<T> {
|
||||
const url = `${this.getBaseUrl()}${endpoint}`;
|
||||
const maxRetries = 3;
|
||||
const retryableStatusCodes = [502, 503, 504]; // Bad Gateway, Service Unavailable, Gateway Timeout
|
||||
|
||||
try {
|
||||
logger.debug(`Jira API request: ${options.method || 'GET'} ${url}`);
|
||||
logger.debug(`Jira API request: ${options.method || 'GET'} ${url}${retryCount > 0 ? ` (retry ${retryCount}/${maxRetries})` : ''}`);
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
@@ -169,11 +184,29 @@ class JiraAssetsService {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Jira API error: ${response.status} - ${errorText}`);
|
||||
const error = new Error(`Jira API error: ${response.status} - ${errorText}`);
|
||||
|
||||
// Retry on temporary gateway errors
|
||||
if (retryableStatusCodes.includes(response.status) && retryCount < maxRetries) {
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCount), 10000); // Exponential backoff, max 10s
|
||||
logger.warn(`Jira API temporary error ${response.status}, retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return this.request<T>(endpoint, options, retryCount + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
} catch (error) {
|
||||
// Retry on network errors (timeouts, connection errors) if we haven't exceeded max retries
|
||||
if (retryCount < maxRetries && error instanceof TypeError && error.message.includes('fetch')) {
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCount), 10000);
|
||||
logger.warn(`Jira API network error, retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return this.request<T>(endpoint, options, retryCount + 1);
|
||||
}
|
||||
|
||||
logger.error(`Jira API request failed: ${endpoint}`, error);
|
||||
throw error;
|
||||
}
|
||||
@@ -285,19 +318,117 @@ class JiraAssetsService {
|
||||
attrSchema?: Map<number, string>
|
||||
): string | null {
|
||||
const attr = this.getAttributeByName(obj, attributeName, attrSchema);
|
||||
if (!attr || attr.objectAttributeValues.length === 0) {
|
||||
return null;
|
||||
|
||||
// Enhanced logging for Reference field
|
||||
if (attributeName === ATTRIBUTE_NAMES.REFERENCE) {
|
||||
if (!attr) {
|
||||
// Log all available attributes with their names and IDs for debugging
|
||||
const availableAttrs = obj.attributes?.map(a => {
|
||||
const schemaName = attrSchema?.get(a.objectTypeAttributeId);
|
||||
const attrName = a.objectTypeAttribute?.name || 'unnamed';
|
||||
return `${attrName} (ID: ${a.objectTypeAttributeId}, schema: ${schemaName || 'none'})`;
|
||||
}).join(', ') || 'none';
|
||||
logger.warn(`Reference attribute "${ATTRIBUTE_NAMES.REFERENCE}" not found for object ${obj.objectKey}.`);
|
||||
logger.warn(`Available attributes (${obj.attributes?.length || 0}): ${availableAttrs}`);
|
||||
|
||||
// Try to find similar attribute names (case-insensitive, partial matches)
|
||||
const similarAttrs = obj.attributes?.filter(a => {
|
||||
const attrName = a.objectTypeAttribute?.name || '';
|
||||
const lowerAttrName = attrName.toLowerCase();
|
||||
return lowerAttrName.includes('reference') || lowerAttrName.includes('enterprise') || lowerAttrName.includes('architect');
|
||||
});
|
||||
if (similarAttrs && similarAttrs.length > 0) {
|
||||
logger.warn(`Found similar attributes that might be the Reference field: ${similarAttrs.map(a => a.objectTypeAttribute?.name || 'unnamed').join(', ')}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
if (attr.objectAttributeValues.length === 0) {
|
||||
logger.warn(`Reference attribute found but has no values for object ${obj.objectKey}. Attribute: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId})`);
|
||||
return null;
|
||||
}
|
||||
logger.info(`Reference attribute found for object ${obj.objectKey}: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId}), values count: ${attr.objectAttributeValues.length}`);
|
||||
} else if (attributeName === ATTRIBUTE_NAMES.CONFLUENCE_SPACE) {
|
||||
// Enhanced logging for Confluence Space field
|
||||
if (!attr) {
|
||||
// Log all available attributes with their names and IDs for debugging
|
||||
const availableAttrs = obj.attributes?.map(a => {
|
||||
const schemaName = attrSchema?.get(a.objectTypeAttributeId);
|
||||
const attrName = a.objectTypeAttribute?.name || 'unnamed';
|
||||
return `${attrName} (ID: ${a.objectTypeAttributeId}, schema: ${schemaName || 'none'})`;
|
||||
}).join(', ') || 'none';
|
||||
logger.warn(`Confluence Space attribute "${ATTRIBUTE_NAMES.CONFLUENCE_SPACE}" not found for object ${obj.objectKey}.`);
|
||||
logger.warn(`Available attributes (${obj.attributes?.length || 0}): ${availableAttrs}`);
|
||||
|
||||
// Try to find similar attribute names (case-insensitive, partial matches)
|
||||
const similarAttrs = obj.attributes?.filter(a => {
|
||||
const attrName = a.objectTypeAttribute?.name || '';
|
||||
const lowerAttrName = attrName.toLowerCase();
|
||||
return lowerAttrName.includes('confluence') || lowerAttrName.includes('space');
|
||||
});
|
||||
if (similarAttrs && similarAttrs.length > 0) {
|
||||
logger.warn(`Found similar attributes that might be the Confluence Space field: ${similarAttrs.map(a => a.objectTypeAttribute?.name || 'unnamed').join(', ')}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
if (attr.objectAttributeValues.length === 0) {
|
||||
logger.warn(`Confluence Space attribute found but has no values for object ${obj.objectKey}. Attribute: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId})`);
|
||||
return null;
|
||||
}
|
||||
logger.info(`Confluence Space attribute found for object ${obj.objectKey}: ${attr.objectTypeAttribute?.name} (ID: ${attr.objectTypeAttributeId}), values count: ${attr.objectAttributeValues.length}`);
|
||||
} else {
|
||||
if (!attr || attr.objectAttributeValues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const value = attr.objectAttributeValues[0];
|
||||
// For select/status fields, use displayValue; for text fields, use value
|
||||
let result: string | null = null;
|
||||
if (value.displayValue !== undefined && value.displayValue !== null) {
|
||||
return value.displayValue;
|
||||
result = String(value.displayValue); // Ensure it's a string
|
||||
} else if (value.value !== undefined && value.value !== null) {
|
||||
result = String(value.value); // Ensure it's a string
|
||||
}
|
||||
if (value.value !== undefined && value.value !== null) {
|
||||
return value.value;
|
||||
|
||||
// Enhanced logging for Reference field
|
||||
if (attributeName === ATTRIBUTE_NAMES.REFERENCE) {
|
||||
logger.info(`Reference field for object ${obj.objectKey}: displayValue="${value.displayValue}" (type: ${typeof value.displayValue}), value="${value.value}" (type: ${typeof value.value}), result="${result}" (type: ${typeof result})`);
|
||||
}
|
||||
return null;
|
||||
|
||||
// Enhanced logging for Confluence Space field
|
||||
if (attributeName === ATTRIBUTE_NAMES.CONFLUENCE_SPACE) {
|
||||
logger.info(`Confluence Space field for object ${obj.objectKey}: displayValue="${value.displayValue}" (type: ${typeof value.displayValue}), value="${value.value}" (type: ${typeof value.value}), result="${result}" (type: ${typeof result})`);
|
||||
logger.info(`Confluence Space raw attribute: ${JSON.stringify(attr, null, 2)}`);
|
||||
}
|
||||
|
||||
// Check if result is the string "undefined" (which shouldn't happen but could)
|
||||
if (result === 'undefined') {
|
||||
logger.warn(`Reference field has string value "undefined" for object ${obj.objectKey}. This indicates a problem with the data.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize empty/whitespace-only strings to null
|
||||
// This handles: empty strings, whitespace-only, Unicode whitespace, zero-width chars
|
||||
if (result !== null && typeof result === 'string') {
|
||||
const trimmed = result.trim();
|
||||
// Check if empty after trim, or only whitespace (including Unicode whitespace)
|
||||
if (trimmed === '' || /^\s*$/.test(result) || trimmed.replace(/[\u200B-\u200D\uFEFF]/g, '') === '') {
|
||||
// Log for Reference field to help debug
|
||||
if (attributeName === ATTRIBUTE_NAMES.REFERENCE) {
|
||||
logger.debug(`Normalizing empty Reference field to null for object ${obj.objectKey}. Original value: "${result}" (length: ${result.length})`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Final logging for Reference field
|
||||
if (attributeName === ATTRIBUTE_NAMES.REFERENCE) {
|
||||
logger.info(`Reference field final result for object ${obj.objectKey}: "${result}"`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get attribute value by attribute ID (useful when we know the ID but not the name)
|
||||
@@ -416,6 +547,146 @@ class JiraAssetsService {
|
||||
}
|
||||
|
||||
// Get reference value with schema fallback for attribute lookup
|
||||
// Helper to extract description from a JiraAssetsObject (same logic as getReferenceObjects)
|
||||
private getDescriptionFromObject(refObj: JiraAssetsObject, refObjSchema?: Map<number, string>): string | undefined {
|
||||
if (!refObj) return undefined;
|
||||
|
||||
if (!refObj.attributes || refObj.attributes.length === 0) {
|
||||
logger.error(`getDescriptionFromObject: Object ${refObj.objectKey} has no attributes array`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// First try: Extract Description attribute using schema lookup (try multiple possible attribute names)
|
||||
// Note: For Description fields, we need to extract the 'value' property from the attribute value object
|
||||
let rawDescription: string | null = null;
|
||||
|
||||
// Try getAttributeValueWithSchema first (handles value.value and value.displayValue)
|
||||
rawDescription = this.getAttributeValueWithSchema(refObj, 'Description', refObjSchema)
|
||||
|| this.getAttributeValueWithSchema(refObj, 'Omschrijving', refObjSchema)
|
||||
|| this.getAttributeValueWithSchema(refObj, 'Beschrijving', refObjSchema);
|
||||
|
||||
// Second try: If not found via schema, search directly in attributes by name
|
||||
// Also check for partial matches and alternative names
|
||||
if (!rawDescription && refObj.attributes && refObj.attributes.length > 0) {
|
||||
for (const attr of refObj.attributes) {
|
||||
// Get attribute name from schema if available, otherwise from objectTypeAttribute
|
||||
let attrName = '';
|
||||
if (refObjSchema && refObjSchema.has(attr.objectTypeAttributeId)) {
|
||||
attrName = refObjSchema.get(attr.objectTypeAttributeId)!;
|
||||
} else if (attr.objectTypeAttribute?.name) {
|
||||
attrName = attr.objectTypeAttribute.name;
|
||||
}
|
||||
const lowerAttrName = attrName.toLowerCase();
|
||||
|
||||
// Check if this attribute name matches description-related names (exact and partial)
|
||||
const isDescriptionAttr =
|
||||
lowerAttrName === 'description' ||
|
||||
lowerAttrName === 'omschrijving' ||
|
||||
lowerAttrName === 'beschrijving' ||
|
||||
lowerAttrName.includes('description') ||
|
||||
lowerAttrName.includes('omschrijving') ||
|
||||
lowerAttrName.includes('beschrijving');
|
||||
|
||||
if (isDescriptionAttr) {
|
||||
// Found description attribute - extract value
|
||||
if (attr.objectAttributeValues && attr.objectAttributeValues.length > 0) {
|
||||
const attrValue = attr.objectAttributeValues[0];
|
||||
if (typeof attrValue === 'string') {
|
||||
rawDescription = attrValue as string;
|
||||
break;
|
||||
} else if (attrValue && typeof attrValue === 'object') {
|
||||
// Try value property first (most common for text fields)
|
||||
if ('value' in attrValue && typeof attrValue.value === 'string' && attrValue.value.trim().length > 0) {
|
||||
rawDescription = attrValue.value as string;
|
||||
break;
|
||||
}
|
||||
// Try displayValue as fallback (for select fields)
|
||||
if ('displayValue' in attrValue && typeof attrValue.displayValue === 'string' && attrValue.displayValue.trim().length > 0) {
|
||||
rawDescription = attrValue.displayValue as string;
|
||||
break;
|
||||
}
|
||||
// Try other possible property names
|
||||
const attrValueObj = attrValue as Record<string, unknown>;
|
||||
for (const key of ['text', 'content', 'html', 'markup']) {
|
||||
const value = attrValueObj[key];
|
||||
if (value && typeof value === 'string') {
|
||||
const strValue = value as string;
|
||||
if (strValue.trim().length > 0) {
|
||||
rawDescription = strValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rawDescription) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Third try: Check ALL attributes for any long text values (might be description stored elsewhere)
|
||||
// Only do this if we still haven't found a description and there are attributes
|
||||
if (!rawDescription && refObj.attributes && refObj.attributes.length > 0) {
|
||||
for (const attr of refObj.attributes) {
|
||||
// Skip attributes we already checked
|
||||
let attrName = '';
|
||||
if (refObjSchema && refObjSchema.has(attr.objectTypeAttributeId)) {
|
||||
attrName = refObjSchema.get(attr.objectTypeAttributeId)!;
|
||||
} else if (attr.objectTypeAttribute?.name) {
|
||||
attrName = attr.objectTypeAttribute.name;
|
||||
}
|
||||
const lowerAttrName = attrName.toLowerCase();
|
||||
|
||||
// Skip standard fields (Key, Name, Created, Updated, etc.)
|
||||
if (['key', 'name', 'label', 'created', 'updated', 'id'].includes(lowerAttrName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attr.objectAttributeValues && attr.objectAttributeValues.length > 0) {
|
||||
const attrValue: unknown = attr.objectAttributeValues[0];
|
||||
let potentialDescription: string | null = null;
|
||||
|
||||
if (typeof attrValue === 'string') {
|
||||
if (attrValue.trim().length > 50) {
|
||||
// Long string might be a description
|
||||
potentialDescription = attrValue;
|
||||
}
|
||||
} else if (attrValue !== null && attrValue !== undefined && typeof attrValue === 'object') {
|
||||
// Check value property
|
||||
const attrValueObj = attrValue as Record<string, unknown>;
|
||||
if ('value' in attrValueObj && typeof attrValueObj.value === 'string') {
|
||||
if (attrValueObj.value.trim().length > 50) {
|
||||
potentialDescription = attrValueObj.value;
|
||||
}
|
||||
} else if ('displayValue' in attrValueObj && typeof attrValueObj.displayValue === 'string') {
|
||||
if (attrValueObj.displayValue.trim().length > 50) {
|
||||
potentialDescription = attrValueObj.displayValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a long text and it looks like a description (not just a short label or ID)
|
||||
if (potentialDescription && potentialDescription.trim().length > 50 && !potentialDescription.match(/^[A-Z0-9-_]+$/)) {
|
||||
rawDescription = potentialDescription;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawDescription) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Strip HTML tags from description (same as getReferenceObjects)
|
||||
if (typeof rawDescription === 'string') {
|
||||
const description = stripHtmlTags(rawDescription);
|
||||
return description || undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async getReferenceValueWithSchema(
|
||||
obj: JiraAssetsObject,
|
||||
attributeName: string,
|
||||
@@ -428,53 +699,188 @@ class JiraAssetsService {
|
||||
|
||||
const value = attr.objectAttributeValues[0];
|
||||
if (value.referencedObject) {
|
||||
return {
|
||||
objectId: value.referencedObject.id.toString(),
|
||||
key: value.referencedObject.objectKey,
|
||||
name: value.referencedObject.label,
|
||||
};
|
||||
// Try to get description from the embedded referenced object
|
||||
// Embedded referenced objects might not have all attributes, so we might need to fetch separately
|
||||
const embeddedRefObj = value.referencedObject;
|
||||
|
||||
// Check factor caches first (they always have descriptions if available)
|
||||
const objectId = embeddedRefObj.id.toString();
|
||||
if (this.dynamicsFactorsCache?.has(objectId)) {
|
||||
return this.dynamicsFactorsCache.get(objectId)!;
|
||||
}
|
||||
if (this.complexityFactorsCache?.has(objectId)) {
|
||||
return this.complexityFactorsCache.get(objectId)!;
|
||||
}
|
||||
if (this.numberOfUsersCache?.has(objectId)) {
|
||||
return this.numberOfUsersCache.get(objectId)!;
|
||||
}
|
||||
if (this.applicationFunctionsCache?.has(objectId)) {
|
||||
return this.applicationFunctionsCache.get(objectId)!;
|
||||
}
|
||||
|
||||
// Check cache - only use if it has description
|
||||
const cached = this.referenceObjectCache.get(embeddedRefObj.objectKey);
|
||||
if (cached && cached.description) {
|
||||
return cached;
|
||||
} else if (cached && !cached.description) {
|
||||
// Remove from cache so we fetch it again
|
||||
this.referenceObjectCache.delete(embeddedRefObj.objectKey);
|
||||
this.referenceObjectCache.delete(embeddedRefObj.id.toString());
|
||||
}
|
||||
|
||||
// Check if there's already a pending request for this object
|
||||
const pendingRequest = this.pendingReferenceRequests.get(objectId);
|
||||
if (pendingRequest) {
|
||||
// Wait for the existing request instead of creating a duplicate
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
// Create a new request and store it in pending requests
|
||||
const fetchPromise = (async (): Promise<ReferenceValue | null> => {
|
||||
// For embedded referenced objects, we need to fetch the full object to get description
|
||||
let description: string | undefined = undefined;
|
||||
|
||||
try {
|
||||
await this.detectApiType();
|
||||
const url = `/object/${embeddedRefObj.id}?includeAttributes=true&includeAttributesDeep=1`;
|
||||
const refObj = await this.request<JiraAssetsObject>(url);
|
||||
|
||||
if (refObj) {
|
||||
if (!refObj.attributes || refObj.attributes.length === 0) {
|
||||
logger.error(`getReferenceValueWithSchema: Object ${refObj.objectKey} has NO ATTRIBUTES despite includeAttributes=true!`);
|
||||
} else {
|
||||
// Fetch attribute schema for the referenced object type
|
||||
let refObjSchema: Map<number, string> | undefined;
|
||||
const refObjectTypeId = refObj.objectType?.id;
|
||||
const refObjectTypeName = refObj.objectType?.name || '';
|
||||
|
||||
if (refObjectTypeId) {
|
||||
try {
|
||||
if (this.attributeSchemaCache.has(refObjectTypeName)) {
|
||||
refObjSchema = this.attributeSchemaCache.get(refObjectTypeName);
|
||||
} else {
|
||||
refObjSchema = await this.fetchAttributeSchema(refObjectTypeId);
|
||||
if (refObjSchema) {
|
||||
this.attributeSchemaCache.set(refObjectTypeName, refObjSchema);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Schema fetch failed, continue without it
|
||||
}
|
||||
}
|
||||
|
||||
// Extract description from the full object
|
||||
description = this.getDescriptionFromObject(refObj, refObjSchema);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`getReferenceValueWithSchema: Could not fetch full object for ${embeddedRefObj.objectKey} (id: ${embeddedRefObj.id})`, error);
|
||||
}
|
||||
|
||||
const refValue: ReferenceValue = {
|
||||
objectId: embeddedRefObj.id.toString(),
|
||||
key: embeddedRefObj.objectKey,
|
||||
name: embeddedRefObj.label,
|
||||
...(description && { description }),
|
||||
};
|
||||
|
||||
// Always cache it for future use (even if description is undefined, so we don't fetch again)
|
||||
this.referenceObjectCache.set(embeddedRefObj.objectKey, refValue);
|
||||
this.referenceObjectCache.set(embeddedRefObj.id.toString(), refValue);
|
||||
|
||||
return refValue;
|
||||
})();
|
||||
|
||||
// Store the pending request
|
||||
this.pendingReferenceRequests.set(objectId, fetchPromise);
|
||||
|
||||
try {
|
||||
const result = await fetchPromise;
|
||||
return result;
|
||||
} finally {
|
||||
// Remove from pending requests when done (success or failure)
|
||||
this.pendingReferenceRequests.delete(objectId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if referencedObject is missing but we have a value, try to fetch it separately
|
||||
// Note: value.value might be an object key (e.g., "GOV-A") or an object ID
|
||||
if (value.value && !value.referencedObject) {
|
||||
// Check cache first
|
||||
// Check cache first - only use if it has description
|
||||
const cached = this.referenceObjectCache.get(value.value);
|
||||
if (cached) {
|
||||
if (cached && cached.description) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to fetch the referenced object by its key or ID
|
||||
// First try as object key (most common)
|
||||
let refObj: JiraAssetsObject | null = null;
|
||||
try {
|
||||
refObj = await this.request<JiraAssetsObject>(`/object/${value.value}`);
|
||||
} catch (keyError) {
|
||||
// If that fails, try as object ID
|
||||
try {
|
||||
refObj = await this.request<JiraAssetsObject>(`/object/${parseInt(value.value, 10)}`);
|
||||
} catch (idError) {
|
||||
// Both failed, log and continue
|
||||
logger.debug(`getReferenceValueWithSchema: Could not fetch referenced object for value "${value.value}" (tried as key and ID) for attribute "${attributeName}" on object ${obj.objectKey}`);
|
||||
}
|
||||
// Check if there's already a pending request for this value
|
||||
const pendingRequest = this.pendingReferenceRequests.get(value.value);
|
||||
if (pendingRequest) {
|
||||
// Wait for the existing request instead of creating a duplicate
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
// Create a new request and store it in pending requests
|
||||
const fetchPromise = (async (): Promise<ReferenceValue | null> => {
|
||||
if (!value.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (refObj) {
|
||||
const refValue: ReferenceValue = {
|
||||
objectId: refObj.id.toString(),
|
||||
key: refObj.objectKey,
|
||||
name: refObj.label,
|
||||
};
|
||||
// Cache it for future use
|
||||
this.referenceObjectCache.set(value.value, refValue);
|
||||
this.referenceObjectCache.set(refObj.objectKey, refValue);
|
||||
this.referenceObjectCache.set(refObj.id.toString(), refValue);
|
||||
return refValue;
|
||||
try {
|
||||
// Try to fetch the referenced object by its key or ID
|
||||
// First try as object key (most common)
|
||||
let refObj: JiraAssetsObject | null = null;
|
||||
try {
|
||||
refObj = await this.request<JiraAssetsObject>(`/object/${value.value}`);
|
||||
} catch (keyError) {
|
||||
// If that fails, try as object ID
|
||||
try {
|
||||
refObj = await this.request<JiraAssetsObject>(`/object/${parseInt(value.value, 10)}`);
|
||||
} catch (idError) {
|
||||
// Both failed, continue
|
||||
}
|
||||
}
|
||||
|
||||
if (refObj) {
|
||||
// Fetch attribute schema for the referenced object type to get description
|
||||
let refObjSchema: Map<number, string> | undefined;
|
||||
const refObjectTypeId = refObj.objectType?.id;
|
||||
if (refObjectTypeId) {
|
||||
try {
|
||||
refObjSchema = await this.fetchAttributeSchema(refObjectTypeId);
|
||||
} catch (error) {
|
||||
// Schema fetch failed, continue without it
|
||||
}
|
||||
}
|
||||
|
||||
const description = this.getDescriptionFromObject(refObj, refObjSchema);
|
||||
|
||||
const refValue: ReferenceValue = {
|
||||
objectId: refObj.id.toString(),
|
||||
key: refObj.objectKey,
|
||||
name: refObj.label,
|
||||
description: description || undefined,
|
||||
};
|
||||
// Cache it for future use
|
||||
this.referenceObjectCache.set(value.value, refValue);
|
||||
this.referenceObjectCache.set(refObj.objectKey, refValue);
|
||||
this.referenceObjectCache.set(refObj.id.toString(), refValue);
|
||||
return refValue;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`getReferenceValueWithSchema: Fallback fetch failed for ${value.value}`, error);
|
||||
}
|
||||
} catch (error) {
|
||||
// If fetching fails, log but don't throw - just return null
|
||||
logger.debug(`getReferenceValueWithSchema: Failed to fetch referenced object ${value.value} for attribute "${attributeName}" on object ${obj.objectKey}`, error);
|
||||
return null;
|
||||
})();
|
||||
|
||||
// Store the pending request
|
||||
this.pendingReferenceRequests.set(value.value, fetchPromise);
|
||||
|
||||
try {
|
||||
const result = await fetchPromise;
|
||||
return result;
|
||||
} finally {
|
||||
// Remove from pending requests when done (success or failure)
|
||||
this.pendingReferenceRequests.delete(value.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,10 +977,6 @@ class JiraAssetsService {
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.PLATFORM, attrSchema),
|
||||
]);
|
||||
|
||||
if (!governanceModel && obj.objectKey) {
|
||||
logger.debug(`parseJiraObject: No governanceModel found for ${obj.objectKey}. Attribute name: ${ATTRIBUTE_NAMES.GOVERNANCE_MODEL}`);
|
||||
}
|
||||
|
||||
const dynamicsFactor = this.enrichWithFactor(dynamicsFactorRaw, this.dynamicsFactorsCache);
|
||||
const complexityFactor = this.enrichWithFactor(complexityFactorRaw, this.complexityFactorsCache);
|
||||
const numberOfUsers = this.enrichWithFactor(numberOfUsersRaw, this.numberOfUsersCache);
|
||||
@@ -651,6 +1053,9 @@ class JiraAssetsService {
|
||||
|
||||
// Parse Jira object for detail view (full details) with optional schema for attribute lookup
|
||||
private async parseJiraObjectDetails(obj: JiraAssetsObject, attrSchema?: Map<number, string>): Promise<ApplicationDetails> {
|
||||
logger.info(`[parseJiraObjectDetails] Parsing object ${obj.objectKey || obj.id} - this is called when fetching directly from Jira API`);
|
||||
logger.info(`[parseJiraObjectDetails] Object has ${obj.attributes?.length || 0} attributes`);
|
||||
|
||||
const appFunctions = this.getReferenceValuesWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_FUNCTION, attrSchema);
|
||||
const rawDescription = this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.DESCRIPTION, attrSchema);
|
||||
|
||||
@@ -670,6 +1075,9 @@ class JiraAssetsService {
|
||||
platform,
|
||||
applicationManagementHosting,
|
||||
applicationManagementTAM,
|
||||
supplierTechnical,
|
||||
supplierImplementation,
|
||||
supplierConsultancy,
|
||||
] = await Promise.all([
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.DYNAMICS_FACTOR, attrSchema),
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.COMPLEXITY_FACTOR, attrSchema),
|
||||
@@ -682,6 +1090,9 @@ class JiraAssetsService {
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.PLATFORM, attrSchema),
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_HOSTING, attrSchema),
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_TAM, attrSchema),
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.SUPPLIER_TECHNICAL, attrSchema),
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.SUPPLIER_IMPLEMENTATION, attrSchema),
|
||||
this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.SUPPLIER_CONSULTANCY, attrSchema),
|
||||
]);
|
||||
|
||||
// Enrich with factors
|
||||
@@ -689,6 +1100,19 @@ class JiraAssetsService {
|
||||
const complexityFactor = this.enrichWithFactor(complexityFactorRaw, this.complexityFactorsCache);
|
||||
const numberOfUsers = this.enrichWithFactor(numberOfUsersRaw, this.numberOfUsersCache);
|
||||
|
||||
// Get Team via Subteam reference if Subteam exists
|
||||
let applicationTeam: ReferenceValue | null = null;
|
||||
if (applicationSubteam?.objectId) {
|
||||
try {
|
||||
// Use the subteam-to-team mapping cache
|
||||
const subteamToTeamMapping = await this.getSubteamToTeamMapping();
|
||||
applicationTeam = subteamToTeamMapping.get(applicationSubteam.objectId) || null;
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to fetch Team via Subteam ${applicationSubteam.objectId}:`, error);
|
||||
// Continue without Team if lookup fails
|
||||
}
|
||||
}
|
||||
|
||||
const applicationDetails: ApplicationDetails = {
|
||||
id: obj.id.toString(),
|
||||
key: obj.objectKey,
|
||||
@@ -716,7 +1140,7 @@ class JiraAssetsService {
|
||||
governanceModel,
|
||||
// "Application Management - Subteam" on ApplicationComponent references Subteam objects
|
||||
applicationSubteam,
|
||||
applicationTeam: null, // Team is looked up via Subteam, not directly on ApplicationComponent
|
||||
applicationTeam, // Team is looked up via Subteam
|
||||
applicationType,
|
||||
platform,
|
||||
requiredEffortApplicationManagement: null,
|
||||
@@ -729,6 +1153,97 @@ class JiraAssetsService {
|
||||
})(),
|
||||
applicationManagementHosting,
|
||||
applicationManagementTAM,
|
||||
reference: (() => {
|
||||
// Try multiple possible attribute names for Reference field
|
||||
const possibleNames = [
|
||||
ATTRIBUTE_NAMES.REFERENCE, // 'Reference'
|
||||
'Enterprise Architect Reference',
|
||||
'EA Reference',
|
||||
'Enterprise Architect',
|
||||
'EA GUID',
|
||||
'GUID',
|
||||
'Reference (EA)',
|
||||
];
|
||||
|
||||
let refValue: string | null = null;
|
||||
let foundAttrName: string | null = null;
|
||||
|
||||
// Try each possible name
|
||||
for (const attrName of possibleNames) {
|
||||
const value = this.getAttributeValueWithSchema(obj, attrName, attrSchema);
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
refValue = value;
|
||||
foundAttrName = attrName;
|
||||
logger.info(`Reference field found for object ${obj.objectKey} using attribute name "${attrName}": "${refValue}"`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If still not found, try manual search through all attributes
|
||||
if (refValue === null || refValue === undefined) {
|
||||
logger.warn(`Reference field not found using standard names for object ${obj.objectKey}. Searching all attributes...`);
|
||||
const allAttrs = obj.attributes || [];
|
||||
|
||||
// Try to find by partial name match
|
||||
let refAttr = allAttrs.find(a => {
|
||||
const schemaName = attrSchema?.get(a.objectTypeAttributeId);
|
||||
const attrName = a.objectTypeAttribute?.name?.toLowerCase() || '';
|
||||
const schemaLower = schemaName?.toLowerCase() || '';
|
||||
|
||||
return attrName.includes('reference') ||
|
||||
schemaLower.includes('reference') ||
|
||||
attrName.includes('enterprise') ||
|
||||
attrName.includes('architect') ||
|
||||
attrName.includes('guid') ||
|
||||
attrName.includes('ea');
|
||||
});
|
||||
|
||||
if (refAttr) {
|
||||
foundAttrName = refAttr.objectTypeAttribute?.name || 'unknown';
|
||||
logger.warn(`Reference attribute found manually: "${foundAttrName}" (ID: ${refAttr.objectTypeAttributeId})`);
|
||||
logger.warn(`Attribute values: ${JSON.stringify(refAttr.objectAttributeValues, null, 2)}`);
|
||||
|
||||
// Try to extract value manually
|
||||
if (refAttr.objectAttributeValues.length > 0) {
|
||||
const value = refAttr.objectAttributeValues[0];
|
||||
const manualValue = value.displayValue !== undefined && value.displayValue !== null
|
||||
? String(value.displayValue)
|
||||
: value.value !== undefined && value.value !== null
|
||||
? String(value.value)
|
||||
: null;
|
||||
|
||||
if (manualValue && manualValue.trim() !== '' && manualValue !== 'undefined') {
|
||||
refValue = manualValue.trim();
|
||||
logger.warn(`Manual extraction found value: "${refValue}" from attribute "${foundAttrName}"`);
|
||||
} else {
|
||||
logger.warn(`Manual extraction found empty/invalid value: "${manualValue}" (type: ${typeof manualValue})`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Log all available attributes for debugging
|
||||
logger.warn(`Reference attribute not found in object ${obj.objectKey}.`);
|
||||
logger.warn(`Available attributes (${allAttrs.length}):`);
|
||||
allAttrs.forEach(a => {
|
||||
const schemaName = attrSchema?.get(a.objectTypeAttributeId);
|
||||
const attrName = a.objectTypeAttribute?.name || 'unnamed';
|
||||
const hasValues = a.objectAttributeValues?.length > 0;
|
||||
logger.warn(` - ${attrName} (ID: ${a.objectTypeAttributeId}, schema: ${schemaName || 'none'}, hasValues: ${hasValues})`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (refValue) {
|
||||
logger.info(`Reference field final result for object ${obj.objectKey}: "${refValue}" (from attribute: ${foundAttrName || 'standard'})`);
|
||||
} else {
|
||||
logger.warn(`Reference field is null/undefined for object ${obj.objectKey} after all attempts.`);
|
||||
}
|
||||
|
||||
return refValue;
|
||||
})(),
|
||||
confluenceSpace: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.CONFLUENCE_SPACE, attrSchema),
|
||||
supplierTechnical,
|
||||
supplierImplementation,
|
||||
supplierConsultancy,
|
||||
};
|
||||
|
||||
// Calculate required effort application management
|
||||
@@ -1201,34 +1716,6 @@ class JiraAssetsService {
|
||||
logger.info(`Cached attribute schema for ${objectType}: ${attrSchema.size} attributes`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log raw API response for first object to debug attribute structure
|
||||
logger.info(`=== Debug: Reference data for ${objectType} ===`);
|
||||
logger.info(`Object: id=${firstObj.id}, key=${firstObj.objectKey}, label=${firstObj.label}`);
|
||||
logger.info(`ObjectType: id=${firstObj.objectType?.id}, name=${firstObj.objectType?.name}`);
|
||||
logger.info(`Attributes count: ${firstObj.attributes?.length || 0}`);
|
||||
if (firstObj.attributes && firstObj.attributes.length > 0) {
|
||||
firstObj.attributes.forEach((attr, idx) => {
|
||||
let attrInfo: string;
|
||||
if (attr.objectTypeAttribute) {
|
||||
attrInfo = `name="${attr.objectTypeAttribute.name}", typeAttrId=${attr.objectTypeAttribute.id}`;
|
||||
} else {
|
||||
// Try to get name from schema
|
||||
const schemaName = attrSchema?.get(attr.objectTypeAttributeId);
|
||||
attrInfo = `(objectTypeAttribute MISSING, attrId=${attr.objectTypeAttributeId}, schemaName="${schemaName || 'unknown'}")`;
|
||||
}
|
||||
const values = attr.objectAttributeValues.map(v => {
|
||||
if (v.displayValue) return `displayValue="${v.displayValue}"`;
|
||||
if (v.value) return `value="${v.value}"`;
|
||||
if (v.referencedObject) return `ref:${v.referencedObject.label}`;
|
||||
return 'empty';
|
||||
}).join(', ');
|
||||
logger.info(` Attr[${idx}]: ${attrInfo} = [${values}]`);
|
||||
});
|
||||
} else {
|
||||
logger.info(` No attributes array or empty!`);
|
||||
}
|
||||
logger.info(`=== End Debug ===`);
|
||||
}
|
||||
|
||||
const results = response.objectEntries.map((obj) => {
|
||||
@@ -1314,11 +1801,6 @@ class JiraAssetsService {
|
||||
return result;
|
||||
});
|
||||
|
||||
// Log first result for debugging
|
||||
if (results.length > 0) {
|
||||
logger.debug(`Reference data for ${objectType}: first item = ${JSON.stringify(results[0])}`);
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get reference objects for type: ${objectType}`, error);
|
||||
@@ -1546,6 +2028,118 @@ class JiraAssetsService {
|
||||
return this.getReferenceObjects('Application Management - TAM');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enriched ReferenceValue with description from cache (if available)
|
||||
* This allows other services to enrich ObjectReferences with descriptions
|
||||
*/
|
||||
getEnrichedReferenceValue(objectKey: string, objectId?: string): ReferenceValue | null {
|
||||
// Try by objectKey first (most common)
|
||||
const cachedByKey = this.referenceObjectCache.get(objectKey);
|
||||
if (cachedByKey) {
|
||||
return cachedByKey;
|
||||
}
|
||||
|
||||
// Try by objectId if provided
|
||||
if (objectId) {
|
||||
const cachedById = this.referenceObjectCache.get(objectId);
|
||||
if (cachedById) {
|
||||
return cachedById;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and enrich ReferenceValue with description (async version that fetches if needed)
|
||||
* This method will:
|
||||
* 1. Check cache first - if found WITH description, return it
|
||||
* 2. If cache miss OR no description, fetch the full object from Jira
|
||||
* 3. Extract description and cache the result
|
||||
* 4. Return the enriched ReferenceValue
|
||||
*/
|
||||
async fetchEnrichedReferenceValue(objectKey: string, objectId?: string): Promise<ReferenceValue | null> {
|
||||
// Check cache first - if we have a cached value WITH description, return it immediately
|
||||
const cachedByKey = this.referenceObjectCache.get(objectKey);
|
||||
let cachedById: ReferenceValue | undefined = undefined;
|
||||
|
||||
if (cachedByKey && cachedByKey.description) {
|
||||
return cachedByKey;
|
||||
}
|
||||
|
||||
if (objectId) {
|
||||
cachedById = this.referenceObjectCache.get(objectId);
|
||||
if (cachedById && cachedById.description) {
|
||||
return cachedById;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or no description - fetch the full object
|
||||
const objectIdToFetch = objectId || objectKey;
|
||||
if (!objectIdToFetch) {
|
||||
logger.warn(`fetchEnrichedReferenceValue: No objectId or objectKey provided`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `/object/${objectIdToFetch}?includeAttributes=true&includeAttributesDeep=1`;
|
||||
const refObj = await this.request<JiraAssetsObject>(url);
|
||||
|
||||
if (!refObj) {
|
||||
logger.warn(`fetchEnrichedReferenceValue: No object returned for ${objectKey}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch attribute schema for the referenced object type
|
||||
let refObjSchema: Map<number, string> | undefined;
|
||||
const refObjectTypeId = refObj.objectType?.id;
|
||||
const refObjectTypeName = refObj.objectType?.name || '';
|
||||
|
||||
if (refObjectTypeId) {
|
||||
try {
|
||||
if (this.attributeSchemaCache.has(refObjectTypeName)) {
|
||||
refObjSchema = this.attributeSchemaCache.get(refObjectTypeName);
|
||||
} else {
|
||||
refObjSchema = await this.fetchAttributeSchema(refObjectTypeId);
|
||||
if (refObjSchema) {
|
||||
this.attributeSchemaCache.set(refObjectTypeName, refObjSchema);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Schema fetch failed, continue without it
|
||||
}
|
||||
}
|
||||
|
||||
// Extract description from the full object
|
||||
const description = this.getDescriptionFromObject(refObj, refObjSchema);
|
||||
|
||||
const refValue: ReferenceValue = {
|
||||
objectId: refObj.id.toString(),
|
||||
key: refObj.objectKey,
|
||||
name: refObj.label,
|
||||
...(description && { description }),
|
||||
};
|
||||
|
||||
// Cache it for future use (by both key and ID)
|
||||
this.referenceObjectCache.set(refObj.objectKey, refValue);
|
||||
this.referenceObjectCache.set(refObj.id.toString(), refValue);
|
||||
|
||||
return refValue;
|
||||
} catch (error) {
|
||||
logger.warn(`fetchEnrichedReferenceValue: Could not fetch object ${objectKey} (id: ${objectIdToFetch})`, error);
|
||||
|
||||
// If we had a cached value without description, return it anyway
|
||||
if (cachedByKey) {
|
||||
return cachedByKey;
|
||||
}
|
||||
if (objectId && cachedById) {
|
||||
return cachedById;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
await this.detectApiType();
|
||||
@@ -2396,7 +2990,7 @@ class JiraAssetsService {
|
||||
`attributes=Key,Object+Type,Label,Name,Description,Status&` +
|
||||
`offset=0&limit=${limit}`;
|
||||
|
||||
logger.info(`CMDB search: ${searchUrl}`);
|
||||
logger.info(`CMDB search API call - Query: "${query}", URL: ${searchUrl}`);
|
||||
|
||||
const response = await fetch(searchUrl, {
|
||||
method: 'GET',
|
||||
|
||||
@@ -49,7 +49,8 @@ class JiraAssetsClient {
|
||||
private baseUrl: string;
|
||||
private defaultHeaders: Record<string, string>;
|
||||
private isDataCenter: boolean | null = null;
|
||||
private requestToken: string | null = null;
|
||||
private serviceAccountToken: string | null = null; // Service account token from .env (for read operations)
|
||||
private requestToken: string | null = null; // User PAT from profile settings (for write operations)
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = `${config.jiraHost}/rest/insight/1.0`;
|
||||
@@ -58,17 +59,18 @@ class JiraAssetsClient {
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
// Add PAT authentication if configured
|
||||
if (config.jiraAuthMethod === 'pat' && config.jiraPat) {
|
||||
this.defaultHeaders['Authorization'] = `Bearer ${config.jiraPat}`;
|
||||
}
|
||||
// Initialize service account token from config (for read operations)
|
||||
this.serviceAccountToken = config.jiraServiceAccountToken || null;
|
||||
|
||||
// User PAT is configured per-user in profile settings
|
||||
// Authorization header is set per-request via setRequestToken()
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Request Token Management (for user-context requests)
|
||||
// ==========================================================================
|
||||
|
||||
setRequestToken(token: string): void {
|
||||
setRequestToken(token: string | null): void {
|
||||
this.requestToken = token;
|
||||
}
|
||||
|
||||
@@ -76,6 +78,21 @@ class JiraAssetsClient {
|
||||
this.requestToken = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is configured for read operations
|
||||
* Uses service account token (primary) or user PAT (fallback)
|
||||
*/
|
||||
hasToken(): boolean {
|
||||
return !!(this.serviceAccountToken || this.requestToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user PAT is configured for write operations
|
||||
*/
|
||||
hasUserToken(): boolean {
|
||||
return !!this.requestToken;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// API Detection
|
||||
// ==========================================================================
|
||||
@@ -95,12 +112,26 @@ class JiraAssetsClient {
|
||||
}
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
/**
|
||||
* Get headers for API requests
|
||||
* @param forWrite - If true, requires user PAT. If false, uses service account token (or user PAT as fallback)
|
||||
*/
|
||||
private getHeaders(forWrite: boolean = false): Record<string, string> {
|
||||
const headers = { ...this.defaultHeaders };
|
||||
|
||||
// Use request-scoped token if available (for user context)
|
||||
if (this.requestToken) {
|
||||
if (forWrite) {
|
||||
// Write operations require user PAT
|
||||
if (!this.requestToken) {
|
||||
throw new Error('Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.');
|
||||
}
|
||||
headers['Authorization'] = `Bearer ${this.requestToken}`;
|
||||
} else {
|
||||
// Read operations: use service account token (primary) or user PAT (fallback)
|
||||
const token = this.serviceAccountToken || this.requestToken;
|
||||
if (!token) {
|
||||
throw new Error('Jira token not configured. Please configure JIRA_SERVICE_ACCOUNT_TOKEN in .env or a Personal Access Token in your user settings.');
|
||||
}
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
@@ -110,15 +141,21 @@ class JiraAssetsClient {
|
||||
// Core API Methods
|
||||
// ==========================================================================
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
/**
|
||||
* Make a request to Jira API
|
||||
* @param endpoint - API endpoint
|
||||
* @param options - Request options
|
||||
* @param forWrite - If true, requires user PAT for write operations
|
||||
*/
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}, forWrite: boolean = false): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
logger.debug(`JiraAssetsClient: ${options.method || 'GET'} ${url}`);
|
||||
logger.debug(`JiraAssetsClient: ${options.method || 'GET'} ${url} (forWrite: ${forWrite})`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...this.getHeaders(),
|
||||
...this.getHeaders(forWrite),
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
@@ -136,10 +173,16 @@ class JiraAssetsClient {
|
||||
// ==========================================================================
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
// Don't test connection if no token is configured
|
||||
if (!this.hasToken()) {
|
||||
logger.debug('JiraAssetsClient: No token configured, skipping connection test');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.detectApiType();
|
||||
const response = await fetch(`${this.baseUrl}/objectschema/${config.jiraSchemaId}`, {
|
||||
headers: this.getHeaders(),
|
||||
headers: this.getHeaders(false), // Read operation - uses service account token
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
@@ -150,7 +193,9 @@ class JiraAssetsClient {
|
||||
|
||||
async getObject(objectId: string): Promise<JiraAssetsObject | null> {
|
||||
try {
|
||||
return await this.request<JiraAssetsObject>(`/object/${objectId}`);
|
||||
// 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
|
||||
} catch (error) {
|
||||
// Check if this is a 404 (object not found / deleted)
|
||||
if (error instanceof Error && error.message.includes('404')) {
|
||||
@@ -182,7 +227,7 @@ class JiraAssetsClient {
|
||||
includeAttributesDeep: '1',
|
||||
objectSchemaId: config.jiraSchemaId,
|
||||
});
|
||||
response = await this.request<JiraAssetsSearchResponse>(`/aql/objects?${params.toString()}`);
|
||||
response = await this.request<JiraAssetsSearchResponse>(`/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}`);
|
||||
@@ -194,7 +239,7 @@ class JiraAssetsClient {
|
||||
includeAttributesDeep: '1',
|
||||
objectSchemaId: config.jiraSchemaId,
|
||||
});
|
||||
response = await this.request<JiraAssetsSearchResponse>(`/iql/objects?${params.toString()}`);
|
||||
response = await this.request<JiraAssetsSearchResponse>(`/iql/objects?${params.toString()}`, {}, false); // Read operation
|
||||
}
|
||||
} else {
|
||||
// Jira Cloud uses POST for AQL
|
||||
@@ -205,8 +250,9 @@ class JiraAssetsClient {
|
||||
page,
|
||||
resultPerPage: pageSize,
|
||||
includeAttributes: true,
|
||||
includeAttributesDeep: 1, // Include attributes of referenced objects (e.g., descriptions)
|
||||
}),
|
||||
});
|
||||
}, false); // Read operation
|
||||
}
|
||||
|
||||
const totalCount = response.totalFilterCount || response.totalCount || 0;
|
||||
@@ -287,6 +333,11 @@ class JiraAssetsClient {
|
||||
}
|
||||
|
||||
async updateObject(objectId: string, payload: JiraUpdatePayload): Promise<boolean> {
|
||||
// Write operations require user PAT
|
||||
if (!this.hasUserToken()) {
|
||||
throw new Error('Jira Personal Access Token not configured. Please configure it in your user settings to enable saving changes to Jira.');
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`JiraAssetsClient.updateObject: Sending update for object ${objectId}`, {
|
||||
attributeCount: payload.attributes.length,
|
||||
@@ -296,7 +347,7 @@ class JiraAssetsClient {
|
||||
await this.request(`/object/${objectId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}, true); // Write operation - requires user PAT
|
||||
|
||||
logger.info(`JiraAssetsClient.updateObject: Successfully updated object ${objectId}`);
|
||||
return true;
|
||||
@@ -337,7 +388,36 @@ class JiraAssetsClient {
|
||||
// Parse each attribute based on schema
|
||||
for (const attrDef of typeDef.attributes) {
|
||||
const jiraAttr = this.findAttribute(jiraObj.attributes, attrDef.jiraId, attrDef.name);
|
||||
result[attrDef.fieldName] = this.parseAttributeValue(jiraAttr, attrDef);
|
||||
const parsedValue = this.parseAttributeValue(jiraAttr, attrDef);
|
||||
result[attrDef.fieldName] = parsedValue;
|
||||
|
||||
// Debug logging for Confluence Space field
|
||||
if (attrDef.fieldName === 'confluenceSpace') {
|
||||
logger.info(`[Confluence Space Debug] Object ${jiraObj.objectKey || jiraObj.id}:`);
|
||||
logger.info(` - Attribute definition: name="${attrDef.name}", jiraId=${attrDef.jiraId}, type="${attrDef.type}"`);
|
||||
logger.info(` - Found attribute: ${jiraAttr ? 'yes' : 'no'}`);
|
||||
if (!jiraAttr) {
|
||||
// Log all available attributes to help debug
|
||||
const availableAttrs = jiraObj.attributes?.map(a => {
|
||||
const attrName = a.objectTypeAttribute?.name || 'unnamed';
|
||||
return `${attrName} (ID: ${a.objectTypeAttributeId})`;
|
||||
}).join(', ') || 'none';
|
||||
logger.warn(` - Available attributes (${jiraObj.attributes?.length || 0}): ${availableAttrs}`);
|
||||
|
||||
// Try to find similar attributes
|
||||
const similarAttrs = jiraObj.attributes?.filter(a => {
|
||||
const attrName = a.objectTypeAttribute?.name || '';
|
||||
const lowerAttrName = attrName.toLowerCase();
|
||||
return lowerAttrName.includes('confluence') || lowerAttrName.includes('space');
|
||||
});
|
||||
if (similarAttrs && similarAttrs.length > 0) {
|
||||
logger.warn(` - Found similar attributes: ${similarAttrs.map(a => a.objectTypeAttribute?.name || 'unnamed').join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
logger.info(` - Raw attribute: ${JSON.stringify(jiraAttr, null, 2)}`);
|
||||
logger.info(` - Parsed value: ${parsedValue} (type: ${typeof parsedValue})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result as T;
|
||||
@@ -363,7 +443,7 @@ class JiraAssetsClient {
|
||||
|
||||
private parseAttributeValue(
|
||||
jiraAttr: JiraAssetsAttribute | undefined,
|
||||
attrDef: { type: string; isMultiple: boolean }
|
||||
attrDef: { type: string; isMultiple: boolean; fieldName?: string }
|
||||
): unknown {
|
||||
if (!jiraAttr?.objectAttributeValues?.length) {
|
||||
return attrDef.isMultiple ? [] : null;
|
||||
@@ -371,6 +451,30 @@ class JiraAssetsClient {
|
||||
|
||||
const values = jiraAttr.objectAttributeValues;
|
||||
|
||||
// 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.)
|
||||
const hasConfluencePage = values.some(v => v.confluencePage);
|
||||
if (hasConfluencePage) {
|
||||
const confluencePage = values[0]?.confluencePage;
|
||||
if (confluencePage?.url) {
|
||||
logger.info(`[Confluence Field Parse] Found Confluence URL for field "${attrDef.fieldName || 'unknown'}": ${confluencePage.url}`);
|
||||
// For multiple values, return array of URLs; for single, return the URL string
|
||||
if (attrDef.isMultiple) {
|
||||
return values
|
||||
.filter(v => v.confluencePage?.url)
|
||||
.map(v => v.confluencePage!.url);
|
||||
}
|
||||
return confluencePage.url;
|
||||
}
|
||||
// Fallback to displayValue if no URL
|
||||
const displayVal = values[0]?.displayValue;
|
||||
if (displayVal) {
|
||||
logger.info(`[Confluence Field Parse] Using displayValue as fallback for field "${attrDef.fieldName || 'unknown'}": ${displayVal}`);
|
||||
return String(displayVal);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (attrDef.type) {
|
||||
case 'reference': {
|
||||
const refs = values
|
||||
@@ -403,8 +507,19 @@ class JiraAssetsClient {
|
||||
}
|
||||
|
||||
case 'float': {
|
||||
// Regular float parsing
|
||||
const val = values[0]?.value;
|
||||
return val ? parseFloat(val) : null;
|
||||
const displayVal = values[0]?.displayValue;
|
||||
// Try displayValue first, then value
|
||||
if (displayVal !== undefined && displayVal !== null) {
|
||||
const parsed = typeof displayVal === 'string' ? parseFloat(displayVal) : Number(displayVal);
|
||||
return isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
if (val !== undefined && val !== null) {
|
||||
const parsed = typeof val === 'string' ? parseFloat(val) : Number(val);
|
||||
return isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'boolean': {
|
||||
|
||||
385
backend/src/services/roleService.ts
Normal file
385
backend/src/services/roleService.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Role Service
|
||||
*
|
||||
* Handles dynamic role and permission management.
|
||||
*/
|
||||
|
||||
import { logger } from './logger.js';
|
||||
import { getAuthDatabase } from './database/migrations.js';
|
||||
|
||||
const isPostgres = (): boolean => {
|
||||
return process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql';
|
||||
};
|
||||
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_system_role: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Permission {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
resource: string | null;
|
||||
}
|
||||
|
||||
export interface CreateRoleInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRoleInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
class RoleService {
|
||||
/**
|
||||
* Get all roles
|
||||
*/
|
||||
async getAllRoles(): Promise<Role[]> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
return await db.query<Role>(
|
||||
'SELECT * FROM roles ORDER BY name'
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role by ID
|
||||
*/
|
||||
async getRoleById(id: number): Promise<Role | null> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
return await db.queryOne<Role>(
|
||||
'SELECT * FROM roles WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role by name
|
||||
*/
|
||||
async getRoleByName(name: string): Promise<Role | null> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
return await db.queryOne<Role>(
|
||||
'SELECT * FROM roles WHERE name = ?',
|
||||
[name]
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new role
|
||||
*/
|
||||
async createRole(input: CreateRoleInput): Promise<Role> {
|
||||
const db = getAuthDatabase();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
// Check if role already exists
|
||||
const existing = await this.getRoleByName(input.name);
|
||||
if (existing) {
|
||||
throw new Error('Role already exists');
|
||||
}
|
||||
|
||||
await db.execute(
|
||||
'INSERT INTO roles (name, description, is_system_role, created_at) VALUES (?, ?, ?, ?)',
|
||||
[input.name, input.description || null, isPostgres() ? false : 0, now]
|
||||
);
|
||||
|
||||
const role = await this.getRoleByName(input.name);
|
||||
if (!role) {
|
||||
throw new Error('Failed to create role');
|
||||
}
|
||||
|
||||
logger.info(`Role created: ${role.name}`);
|
||||
return role;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update role
|
||||
*/
|
||||
async updateRole(id: number, input: UpdateRoleInput): Promise<Role> {
|
||||
const db = getAuthDatabase();
|
||||
|
||||
try {
|
||||
const role = await this.getRoleById(id);
|
||||
if (!role) {
|
||||
throw new Error('Role not found');
|
||||
}
|
||||
|
||||
if (role.is_system_role) {
|
||||
throw new Error('Cannot update system role');
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (input.name !== undefined) {
|
||||
// Check if name already exists for another role
|
||||
const existing = await db.queryOne<Role>(
|
||||
'SELECT id FROM roles WHERE name = ? AND id != ?',
|
||||
[input.name, id]
|
||||
);
|
||||
if (existing) {
|
||||
throw new Error('Role name already exists');
|
||||
}
|
||||
updates.push('name = ?');
|
||||
values.push(input.name);
|
||||
}
|
||||
|
||||
if (input.description !== undefined) {
|
||||
updates.push('description = ?');
|
||||
values.push(input.description);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return role;
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
|
||||
await db.execute(
|
||||
`UPDATE roles SET ${updates.join(', ')} WHERE id = ?`,
|
||||
values
|
||||
);
|
||||
|
||||
const updated = await this.getRoleById(id);
|
||||
if (!updated) {
|
||||
throw new Error('Role not found');
|
||||
}
|
||||
|
||||
logger.info(`Role updated: ${updated.name}`);
|
||||
return updated;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete role
|
||||
*/
|
||||
async deleteRole(id: number): Promise<boolean> {
|
||||
const db = getAuthDatabase();
|
||||
|
||||
try {
|
||||
const role = await this.getRoleById(id);
|
||||
if (!role) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (role.is_system_role) {
|
||||
throw new Error('Cannot delete system role');
|
||||
}
|
||||
|
||||
const result = await db.execute(
|
||||
'DELETE FROM roles WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
logger.info(`Role deleted: ${role.name}`);
|
||||
return result > 0;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions
|
||||
*/
|
||||
async getAllPermissions(): Promise<Permission[]> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
return await db.query<Permission>(
|
||||
'SELECT * FROM permissions ORDER BY resource, name'
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permission by ID
|
||||
*/
|
||||
async getPermissionById(id: number): Promise<Permission | null> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
return await db.queryOne<Permission>(
|
||||
'SELECT * FROM permissions WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permission by name
|
||||
*/
|
||||
async getPermissionByName(name: string): Promise<Permission | null> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
return await db.queryOne<Permission>(
|
||||
'SELECT * FROM permissions WHERE name = ?',
|
||||
[name]
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permissions for a role
|
||||
*/
|
||||
async getRolePermissions(roleId: number): Promise<Permission[]> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
return await db.query<Permission>(
|
||||
`SELECT p.* FROM permissions p
|
||||
INNER JOIN role_permissions rp ON p.id = rp.permission_id
|
||||
WHERE rp.role_id = ?
|
||||
ORDER BY p.resource, p.name`,
|
||||
[roleId]
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign permission to role
|
||||
*/
|
||||
async assignPermissionToRole(roleId: number, permissionId: number): Promise<boolean> {
|
||||
const db = getAuthDatabase();
|
||||
|
||||
try {
|
||||
await db.execute(
|
||||
`INSERT INTO role_permissions (role_id, permission_id)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(role_id, permission_id) DO NOTHING`,
|
||||
[roleId, permissionId]
|
||||
);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
// Handle SQLite (no ON CONFLICT support)
|
||||
if (error.message?.includes('UNIQUE constraint')) {
|
||||
return false; // Already assigned
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove permission from role
|
||||
*/
|
||||
async removePermissionFromRole(roleId: number, permissionId: number): Promise<boolean> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
const result = await db.execute(
|
||||
'DELETE FROM role_permissions WHERE role_id = ? AND permission_id = ?',
|
||||
[roleId, permissionId]
|
||||
);
|
||||
return result > 0;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user permissions (from all roles)
|
||||
*/
|
||||
async getUserPermissions(userId: number): Promise<Permission[]> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
return await db.query<Permission>(
|
||||
`SELECT DISTINCT p.* FROM permissions p
|
||||
INNER JOIN role_permissions rp ON p.id = rp.permission_id
|
||||
INNER JOIN user_roles ur ON rp.role_id = ur.role_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY p.resource, p.name`,
|
||||
[userId]
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has permission
|
||||
*/
|
||||
async userHasPermission(userId: number, permissionName: string): Promise<boolean> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
const result = await db.queryOne<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM permissions p
|
||||
INNER JOIN role_permissions rp ON p.id = rp.permission_id
|
||||
INNER JOIN user_roles ur ON rp.role_id = ur.role_id
|
||||
WHERE ur.user_id = ? AND p.name = ?`,
|
||||
[userId, permissionName]
|
||||
);
|
||||
|
||||
const count = isPostgres() ? (result?.count || 0) : (result?.count || 0);
|
||||
return parseInt(String(count)) > 0;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has role
|
||||
*/
|
||||
async userHasRole(userId: number, roleName: string): Promise<boolean> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
const result = await db.queryOne<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM roles r
|
||||
INNER JOIN user_roles ur ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ? AND r.name = ?`,
|
||||
[userId, roleName]
|
||||
);
|
||||
|
||||
const count = isPostgres() ? (result?.count || 0) : (result?.count || 0);
|
||||
return parseInt(String(count)) > 0;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user roles
|
||||
*/
|
||||
async getUserRoles(userId: number): Promise<Role[]> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
return await db.query<Role>(
|
||||
`SELECT r.* FROM roles r
|
||||
INNER JOIN user_roles ur ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY r.name`,
|
||||
[userId]
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const roleService = new RoleService();
|
||||
@@ -80,6 +80,8 @@ class SyncEngine {
|
||||
/**
|
||||
* Initialize the sync engine
|
||||
* Performs initial sync if cache is cold, then starts incremental sync
|
||||
* Note: Sync engine uses service account token from .env (JIRA_SERVICE_ACCOUNT_TOKEN)
|
||||
* for all read operations. Write operations require user PAT from profile settings.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
@@ -88,27 +90,11 @@ class SyncEngine {
|
||||
}
|
||||
|
||||
logger.info('SyncEngine: Initializing...');
|
||||
logger.info('SyncEngine: Sync uses service account token (JIRA_SERVICE_ACCOUNT_TOKEN) from .env');
|
||||
this.isRunning = true;
|
||||
|
||||
// Check if we need a full sync
|
||||
const stats = await cacheStore.getStats();
|
||||
const lastFullSync = stats.lastFullSync;
|
||||
const needsFullSync = !stats.isWarm || !lastFullSync || this.isStale(lastFullSync, 24 * 60 * 60 * 1000);
|
||||
|
||||
if (needsFullSync) {
|
||||
logger.info('SyncEngine: Cache is cold or stale, starting full sync in background...');
|
||||
// Run full sync in background (non-blocking)
|
||||
this.fullSync().catch(err => {
|
||||
logger.error('SyncEngine: Background full sync failed', err);
|
||||
});
|
||||
} else {
|
||||
logger.info('SyncEngine: Cache is warm, skipping initial full sync');
|
||||
}
|
||||
|
||||
// Start incremental sync scheduler
|
||||
this.startIncrementalSyncScheduler();
|
||||
|
||||
logger.info('SyncEngine: Initialized');
|
||||
// Sync can run automatically using service account token
|
||||
logger.info('SyncEngine: Initialized (using service account token for sync operations)');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,8 +126,22 @@ class SyncEngine {
|
||||
|
||||
/**
|
||||
* Perform a full sync of all object types
|
||||
* Uses service account token from .env (JIRA_SERVICE_ACCOUNT_TOKEN)
|
||||
*/
|
||||
async fullSync(): Promise<SyncResult> {
|
||||
// Check if service account token is configured (sync uses service account token)
|
||||
if (!jiraAssetsClient.hasToken()) {
|
||||
logger.warn('SyncEngine: Jira service account token not configured, cannot perform sync');
|
||||
return {
|
||||
success: false,
|
||||
stats: [],
|
||||
totalObjects: 0,
|
||||
totalRelations: 0,
|
||||
duration: 0,
|
||||
error: 'Jira service account token (JIRA_SERVICE_ACCOUNT_TOKEN) not configured in .env. Please configure it to enable sync operations.',
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isSyncing) {
|
||||
logger.warn('SyncEngine: Sync already in progress');
|
||||
return {
|
||||
@@ -312,11 +312,18 @@ class SyncEngine {
|
||||
|
||||
/**
|
||||
* Perform an incremental sync (only updated objects)
|
||||
* Uses service account token from .env (JIRA_SERVICE_ACCOUNT_TOKEN)
|
||||
*
|
||||
* Note: On Jira Data Center, IQL-based incremental sync is not supported.
|
||||
* We instead check if a periodic full sync is needed.
|
||||
*/
|
||||
async incrementalSync(): Promise<{ success: boolean; updatedCount: number }> {
|
||||
// Check if service account token is configured (sync uses service account token)
|
||||
if (!jiraAssetsClient.hasToken()) {
|
||||
logger.debug('SyncEngine: Jira service account token not configured, skipping incremental sync');
|
||||
return { success: false, updatedCount: 0 };
|
||||
}
|
||||
|
||||
if (this.isSyncing) {
|
||||
return { success: false, updatedCount: 0 };
|
||||
}
|
||||
|
||||
616
backend/src/services/userService.ts
Normal file
616
backend/src/services/userService.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* User Service
|
||||
*
|
||||
* Handles user CRUD operations, password management, email verification, and role assignment.
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { logger } from './logger.js';
|
||||
import { getAuthDatabase } from './database/migrations.js';
|
||||
import { emailService } from './emailService.js';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
const isPostgres = (): boolean => {
|
||||
return process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql';
|
||||
};
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
display_name: string | null;
|
||||
is_active: boolean;
|
||||
email_verified: boolean;
|
||||
email_verification_token: string | null;
|
||||
password_reset_token: string | null;
|
||||
password_reset_expires: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_login: string | null;
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
email: string;
|
||||
username: string;
|
||||
password?: string;
|
||||
display_name?: string;
|
||||
send_invitation?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserInput {
|
||||
email?: string;
|
||||
username?: string;
|
||||
display_name?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
class UserService {
|
||||
/**
|
||||
* Hash a password
|
||||
*/
|
||||
async hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password
|
||||
*/
|
||||
async verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure random token
|
||||
*/
|
||||
generateToken(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
*/
|
||||
async createUser(input: CreateUserInput): Promise<User> {
|
||||
const db = getAuthDatabase();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
// Check if email or username already exists
|
||||
const existingEmail = await db.queryOne<User>(
|
||||
'SELECT id FROM users WHERE email = ?',
|
||||
[input.email]
|
||||
);
|
||||
if (existingEmail) {
|
||||
throw new Error('Email already exists');
|
||||
}
|
||||
|
||||
const existingUsername = await db.queryOne<User>(
|
||||
'SELECT id FROM users WHERE username = ?',
|
||||
[input.username]
|
||||
);
|
||||
if (existingUsername) {
|
||||
throw new Error('Username already exists');
|
||||
}
|
||||
|
||||
// Hash password if provided
|
||||
let passwordHash = '';
|
||||
if (input.password) {
|
||||
passwordHash = await this.hashPassword(input.password);
|
||||
} else {
|
||||
// Generate a temporary password hash (user will set password via invitation)
|
||||
passwordHash = await this.hashPassword(this.generateToken());
|
||||
}
|
||||
|
||||
// Generate email verification token
|
||||
const emailVerificationToken = this.generateToken();
|
||||
|
||||
// Insert user
|
||||
await db.execute(
|
||||
`INSERT INTO users (
|
||||
email, username, password_hash, display_name,
|
||||
is_active, email_verified, email_verification_token,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
input.email,
|
||||
input.username,
|
||||
passwordHash,
|
||||
input.display_name || null,
|
||||
isPostgres() ? true : 1,
|
||||
isPostgres() ? false : 0,
|
||||
emailVerificationToken,
|
||||
now,
|
||||
now,
|
||||
]
|
||||
);
|
||||
|
||||
const user = await db.queryOne<User>(
|
||||
'SELECT * FROM users WHERE email = ?',
|
||||
[input.email]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Failed to create user');
|
||||
}
|
||||
|
||||
// Send invitation email if requested
|
||||
if (input.send_invitation && !input.password) {
|
||||
await this.sendInvitation(user.id);
|
||||
}
|
||||
|
||||
logger.info(`User created: ${user.email}`);
|
||||
return user;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
*/
|
||||
async getUserById(id: number): Promise<User | null> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
return await db.queryOne<User>(
|
||||
'SELECT * FROM users WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by email
|
||||
*/
|
||||
async getUserByEmail(email: string): Promise<User | null> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
return await db.queryOne<User>(
|
||||
'SELECT * FROM users WHERE email = ?',
|
||||
[email]
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by username
|
||||
*/
|
||||
async getUserByUsername(username: string): Promise<User | null> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
return await db.queryOne<User>(
|
||||
'SELECT * FROM users WHERE username = ?',
|
||||
[username]
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users
|
||||
*/
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
return await db.query<User>(
|
||||
'SELECT * FROM users ORDER BY created_at DESC'
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user
|
||||
*/
|
||||
async updateUser(id: number, input: UpdateUserInput): Promise<User> {
|
||||
const db = getAuthDatabase();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (input.email !== undefined) {
|
||||
// Check if email already exists for another user
|
||||
const existing = await db.queryOne<User>(
|
||||
'SELECT id FROM users WHERE email = ? AND id != ?',
|
||||
[input.email, id]
|
||||
);
|
||||
if (existing) {
|
||||
throw new Error('Email already exists');
|
||||
}
|
||||
updates.push('email = ?');
|
||||
values.push(input.email);
|
||||
}
|
||||
|
||||
if (input.username !== undefined) {
|
||||
// Check if username already exists for another user
|
||||
const existing = await db.queryOne<User>(
|
||||
'SELECT id FROM users WHERE username = ? AND id != ?',
|
||||
[input.username, id]
|
||||
);
|
||||
if (existing) {
|
||||
throw new Error('Username already exists');
|
||||
}
|
||||
updates.push('username = ?');
|
||||
values.push(input.username);
|
||||
}
|
||||
|
||||
if (input.display_name !== undefined) {
|
||||
updates.push('display_name = ?');
|
||||
values.push(input.display_name);
|
||||
}
|
||||
|
||||
if (input.is_active !== undefined) {
|
||||
updates.push('is_active = ?');
|
||||
values.push(isPostgres() ? input.is_active : (input.is_active ? 1 : 0));
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
const user = await this.getUserById(id);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
updates.push('updated_at = ?');
|
||||
values.push(now);
|
||||
values.push(id);
|
||||
|
||||
await db.execute(
|
||||
`UPDATE users SET ${updates.join(', ')} WHERE id = ?`,
|
||||
values
|
||||
);
|
||||
|
||||
const user = await this.getUserById(id);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
logger.info(`User updated: ${user.email}`);
|
||||
return user;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user
|
||||
*/
|
||||
async deleteUser(id: number): Promise<boolean> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
const result = await db.execute(
|
||||
'DELETE FROM users WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
logger.info(`User deleted: ${id}`);
|
||||
return result > 0;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user password
|
||||
*/
|
||||
async updatePassword(id: number, newPassword: string): Promise<void> {
|
||||
const db = getAuthDatabase();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const passwordHash = await this.hashPassword(newPassword);
|
||||
await db.execute(
|
||||
'UPDATE users SET password_hash = ?, password_reset_token = NULL, password_reset_expires = NULL, updated_at = ? WHERE id = ?',
|
||||
[passwordHash, now, id]
|
||||
);
|
||||
logger.info(`Password updated for user: ${id}`);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and store password reset token
|
||||
*/
|
||||
async generatePasswordResetToken(email: string): Promise<string | null> {
|
||||
const db = getAuthDatabase();
|
||||
const user = await this.getUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
// Don't reveal if user exists
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = this.generateToken();
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
|
||||
|
||||
await db.execute(
|
||||
'UPDATE users SET password_reset_token = ?, password_reset_expires = ? WHERE id = ?',
|
||||
[token, expiresAt, user.id]
|
||||
);
|
||||
|
||||
// Store in email_tokens table as well
|
||||
await db.execute(
|
||||
`INSERT INTO email_tokens (user_id, token, type, expires_at, used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[user.id, token, 'password_reset', expiresAt, isPostgres() ? false : 0, new Date().toISOString()]
|
||||
);
|
||||
|
||||
// Send password reset email
|
||||
await emailService.sendPasswordResetEmail(user.email, token, user.display_name || undefined);
|
||||
|
||||
return token;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password using token
|
||||
*/
|
||||
async resetPasswordWithToken(token: string, newPassword: string): Promise<boolean> {
|
||||
const db = getAuthDatabase();
|
||||
|
||||
try {
|
||||
// Check token in email_tokens table
|
||||
const tokenRecord = await db.queryOne<{ user_id: number; expires_at: string; used: boolean }>(
|
||||
`SELECT user_id, expires_at, used FROM email_tokens
|
||||
WHERE token = ? AND type = 'password_reset' AND used = ?`,
|
||||
[token, isPostgres() ? false : 0]
|
||||
);
|
||||
|
||||
if (!tokenRecord) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (new Date(tokenRecord.expires_at) < new Date()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update password
|
||||
await this.updatePassword(tokenRecord.user_id, newPassword);
|
||||
|
||||
// Mark token as used
|
||||
await db.execute(
|
||||
'UPDATE email_tokens SET used = ? WHERE token = ?',
|
||||
[isPostgres() ? true : 1, token]
|
||||
);
|
||||
|
||||
logger.info(`Password reset completed for user: ${tokenRecord.user_id}`);
|
||||
return true;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify email with token
|
||||
*/
|
||||
async verifyEmail(token: string): Promise<boolean> {
|
||||
const db = getAuthDatabase();
|
||||
|
||||
try {
|
||||
const user = await db.queryOne<User>(
|
||||
'SELECT * FROM users WHERE email_verification_token = ?',
|
||||
[token]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await db.execute(
|
||||
'UPDATE users SET email_verified = ?, email_verification_token = NULL, updated_at = ? WHERE id = ?',
|
||||
[isPostgres() ? true : 1, now, user.id]
|
||||
);
|
||||
|
||||
logger.info(`Email verified for user: ${user.email}`);
|
||||
return true;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually verify email address (admin action)
|
||||
*/
|
||||
async manuallyVerifyEmail(id: number): Promise<void> {
|
||||
const db = getAuthDatabase();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
await db.execute(
|
||||
'UPDATE users SET email_verified = ?, email_verification_token = NULL, updated_at = ? WHERE id = ?',
|
||||
[isPostgres() ? true : 1, now, id]
|
||||
);
|
||||
logger.info(`Email manually verified for user: ${id}`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send invitation email
|
||||
*/
|
||||
async sendInvitation(userId: number): Promise<boolean> {
|
||||
const db = getAuthDatabase();
|
||||
|
||||
try {
|
||||
const user = await this.getUserById(userId);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = this.generateToken();
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days
|
||||
|
||||
// Store invitation token
|
||||
await db.execute(
|
||||
`INSERT INTO email_tokens (user_id, token, type, expires_at, used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[userId, token, 'invitation', expiresAt, isPostgres() ? false : 0, new Date().toISOString()]
|
||||
);
|
||||
|
||||
// Send invitation email
|
||||
return await emailService.sendInvitationEmail(
|
||||
user.email,
|
||||
token,
|
||||
user.display_name || undefined
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate invitation token
|
||||
*/
|
||||
async validateInvitationToken(token: string): Promise<User | null> {
|
||||
const db = getAuthDatabase();
|
||||
|
||||
try {
|
||||
const tokenRecord = await db.queryOne<{ user_id: number; expires_at: string; used: boolean }>(
|
||||
`SELECT user_id, expires_at, used FROM email_tokens
|
||||
WHERE token = ? AND type = 'invitation' AND used = ?`,
|
||||
[token, isPostgres() ? false : 0]
|
||||
);
|
||||
|
||||
if (!tokenRecord) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (new Date(tokenRecord.expires_at) < new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.getUserById(tokenRecord.user_id);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept invitation and set password
|
||||
*/
|
||||
async acceptInvitation(token: string, password: string): Promise<User | null> {
|
||||
const db = getAuthDatabase();
|
||||
|
||||
try {
|
||||
const user = await this.validateInvitationToken(token);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update password
|
||||
await this.updatePassword(user.id, password);
|
||||
|
||||
// Mark token as used
|
||||
await db.execute(
|
||||
'UPDATE email_tokens SET used = ? WHERE token = ?',
|
||||
[isPostgres() ? true : 1, token]
|
||||
);
|
||||
|
||||
// Activate user and verify email
|
||||
const now = new Date().toISOString();
|
||||
await db.execute(
|
||||
'UPDATE users SET is_active = ?, email_verified = ?, updated_at = ? WHERE id = ?',
|
||||
[isPostgres() ? true : 1, isPostgres() ? true : 1, now, user.id]
|
||||
);
|
||||
|
||||
return await this.getUserById(user.id);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last login timestamp
|
||||
*/
|
||||
async updateLastLogin(id: number): Promise<void> {
|
||||
const db = getAuthDatabase();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
await db.execute(
|
||||
'UPDATE users SET last_login = ? WHERE id = ?',
|
||||
[now, id]
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user roles
|
||||
*/
|
||||
async getUserRoles(userId: number): Promise<Array<{ id: number; name: string; description: string | null }>> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
return await db.query<{ id: number; name: string; description: string | null }>(
|
||||
`SELECT r.id, r.name, r.description
|
||||
FROM roles r
|
||||
INNER JOIN user_roles ur ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ?`,
|
||||
[userId]
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign role to user
|
||||
*/
|
||||
async assignRole(userId: number, roleId: number): Promise<boolean> {
|
||||
const db = getAuthDatabase();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
await db.execute(
|
||||
`INSERT INTO user_roles (user_id, role_id, assigned_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, role_id) DO NOTHING`,
|
||||
[userId, roleId, now]
|
||||
);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
// Handle SQLite (no ON CONFLICT support)
|
||||
if (error.message?.includes('UNIQUE constraint')) {
|
||||
return false; // Already assigned
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove role from user
|
||||
*/
|
||||
async removeRole(userId: number, roleId: number): Promise<boolean> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
const result = await db.execute(
|
||||
'DELETE FROM user_roles WHERE user_id = ? AND role_id = ?',
|
||||
[userId, roleId]
|
||||
);
|
||||
return result > 0;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const userService = new UserService();
|
||||
298
backend/src/services/userSettingsService.ts
Normal file
298
backend/src/services/userSettingsService.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* User Settings Service
|
||||
*
|
||||
* Manages user-specific settings including Jira PAT, AI features, and API keys.
|
||||
*/
|
||||
|
||||
import { logger } from './logger.js';
|
||||
import { getAuthDatabase } from './database/migrations.js';
|
||||
import { encryptionService } from './encryptionService.js';
|
||||
import { config } from '../config/env.js';
|
||||
|
||||
const isPostgres = (): boolean => {
|
||||
return process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql';
|
||||
};
|
||||
|
||||
export interface UserSettings {
|
||||
user_id: number;
|
||||
jira_pat: string | null;
|
||||
jira_pat_encrypted: boolean;
|
||||
ai_enabled: boolean;
|
||||
ai_provider: string | null;
|
||||
ai_api_key: string | null;
|
||||
web_search_enabled: boolean;
|
||||
tavily_api_key: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserSettingsInput {
|
||||
jira_pat?: string;
|
||||
ai_enabled?: boolean;
|
||||
ai_provider?: 'openai' | 'anthropic';
|
||||
ai_api_key?: string;
|
||||
web_search_enabled?: boolean;
|
||||
tavily_api_key?: string;
|
||||
}
|
||||
|
||||
class UserSettingsService {
|
||||
/**
|
||||
* Get user settings
|
||||
*/
|
||||
async getUserSettings(userId: number): Promise<UserSettings | null> {
|
||||
const db = getAuthDatabase();
|
||||
try {
|
||||
const settings = await db.queryOne<UserSettings>(
|
||||
'SELECT * FROM user_settings WHERE user_id = ?',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!settings) {
|
||||
// Create default settings
|
||||
return await this.createDefaultSettings(userId);
|
||||
}
|
||||
|
||||
// Decrypt sensitive fields if encrypted
|
||||
if (settings.jira_pat && settings.jira_pat_encrypted && encryptionService.isConfigured()) {
|
||||
try {
|
||||
settings.jira_pat = await encryptionService.decrypt(settings.jira_pat);
|
||||
} catch (error) {
|
||||
logger.error('Failed to decrypt Jira PAT:', error);
|
||||
settings.jira_pat = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.ai_api_key && encryptionService.isConfigured()) {
|
||||
try {
|
||||
settings.ai_api_key = await encryptionService.decrypt(settings.ai_api_key);
|
||||
} catch (error) {
|
||||
logger.error('Failed to decrypt AI API key:', error);
|
||||
settings.ai_api_key = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.tavily_api_key && encryptionService.isConfigured()) {
|
||||
try {
|
||||
settings.tavily_api_key = await encryptionService.decrypt(settings.tavily_api_key);
|
||||
} catch (error) {
|
||||
logger.error('Failed to decrypt Tavily API key:', error);
|
||||
settings.tavily_api_key = null;
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default settings for user
|
||||
*/
|
||||
async createDefaultSettings(userId: number): Promise<UserSettings> {
|
||||
const db = getAuthDatabase();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
await db.execute(
|
||||
`INSERT INTO user_settings (
|
||||
user_id, jira_pat, jira_pat_encrypted, ai_enabled, ai_provider,
|
||||
ai_api_key, web_search_enabled, tavily_api_key, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
userId,
|
||||
null,
|
||||
isPostgres() ? true : 1,
|
||||
isPostgres() ? false : 0,
|
||||
null,
|
||||
null,
|
||||
isPostgres() ? false : 0,
|
||||
null,
|
||||
now,
|
||||
]
|
||||
);
|
||||
|
||||
return await this.getUserSettings(userId) as UserSettings;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user settings
|
||||
*/
|
||||
async updateUserSettings(userId: number, input: UpdateUserSettingsInput): Promise<UserSettings> {
|
||||
const db = getAuthDatabase();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
// Ensure settings exist
|
||||
let settings = await this.getUserSettings(userId);
|
||||
if (!settings) {
|
||||
settings = await this.createDefaultSettings(userId);
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (input.jira_pat !== undefined) {
|
||||
let encryptedPat: string | null = null;
|
||||
if (input.jira_pat) {
|
||||
if (encryptionService.isConfigured()) {
|
||||
encryptedPat = await encryptionService.encrypt(input.jira_pat);
|
||||
} else {
|
||||
// Store unencrypted if encryption not configured (development)
|
||||
encryptedPat = input.jira_pat;
|
||||
}
|
||||
}
|
||||
updates.push('jira_pat = ?');
|
||||
updates.push('jira_pat_encrypted = ?');
|
||||
values.push(encryptedPat);
|
||||
values.push(encryptionService.isConfigured() ? (isPostgres() ? true : 1) : (isPostgres() ? false : 0));
|
||||
}
|
||||
|
||||
if (input.ai_enabled !== undefined) {
|
||||
updates.push('ai_enabled = ?');
|
||||
values.push(isPostgres() ? input.ai_enabled : (input.ai_enabled ? 1 : 0));
|
||||
}
|
||||
|
||||
if (input.ai_provider !== undefined) {
|
||||
updates.push('ai_provider = ?');
|
||||
values.push(input.ai_provider);
|
||||
}
|
||||
|
||||
if (input.ai_api_key !== undefined) {
|
||||
let encryptedKey: string | null = null;
|
||||
if (input.ai_api_key) {
|
||||
if (encryptionService.isConfigured()) {
|
||||
encryptedKey = await encryptionService.encrypt(input.ai_api_key);
|
||||
} else {
|
||||
encryptedKey = input.ai_api_key;
|
||||
}
|
||||
}
|
||||
updates.push('ai_api_key = ?');
|
||||
values.push(encryptedKey);
|
||||
}
|
||||
|
||||
if (input.web_search_enabled !== undefined) {
|
||||
updates.push('web_search_enabled = ?');
|
||||
values.push(isPostgres() ? input.web_search_enabled : (input.web_search_enabled ? 1 : 0));
|
||||
}
|
||||
|
||||
if (input.tavily_api_key !== undefined) {
|
||||
let encryptedKey: string | null = null;
|
||||
if (input.tavily_api_key) {
|
||||
if (encryptionService.isConfigured()) {
|
||||
encryptedKey = await encryptionService.encrypt(input.tavily_api_key);
|
||||
} else {
|
||||
encryptedKey = input.tavily_api_key;
|
||||
}
|
||||
}
|
||||
updates.push('tavily_api_key = ?');
|
||||
values.push(encryptedKey);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
updates.push('updated_at = ?');
|
||||
values.push(now);
|
||||
values.push(userId);
|
||||
|
||||
await db.execute(
|
||||
`UPDATE user_settings SET ${updates.join(', ')} WHERE user_id = ?`,
|
||||
values
|
||||
);
|
||||
|
||||
logger.info(`User settings updated for user: ${userId}`);
|
||||
return await this.getUserSettings(userId) as UserSettings;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Jira PAT by testing connection
|
||||
*/
|
||||
async validateJiraPat(userId: number, pat?: string): Promise<boolean> {
|
||||
try {
|
||||
const settings = await this.getUserSettings(userId);
|
||||
const tokenToTest = pat || settings?.jira_pat;
|
||||
|
||||
if (!tokenToTest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Test connection to Jira
|
||||
const testUrl = `${config.jiraHost}/rest/api/2/myself`;
|
||||
const response = await fetch(testUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${tokenToTest}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
logger.error('Jira PAT validation failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Jira PAT status
|
||||
*/
|
||||
async getJiraPatStatus(userId: number): Promise<{ configured: boolean; valid: boolean }> {
|
||||
const settings = await this.getUserSettings(userId);
|
||||
const configured = !!settings?.jira_pat;
|
||||
|
||||
if (!configured) {
|
||||
return { configured: false, valid: false };
|
||||
}
|
||||
|
||||
const valid = await this.validateJiraPat(userId);
|
||||
return { configured: true, valid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if AI features are enabled for user
|
||||
*/
|
||||
async isAiEnabled(userId: number): Promise<boolean> {
|
||||
const settings = await this.getUserSettings(userId);
|
||||
return settings?.ai_enabled || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AI provider for user
|
||||
*/
|
||||
async getAiProvider(userId: number): Promise<'openai' | 'anthropic' | null> {
|
||||
const settings = await this.getUserSettings(userId);
|
||||
return (settings?.ai_provider as 'openai' | 'anthropic') || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AI API key for user
|
||||
*/
|
||||
async getAiApiKey(userId: number): Promise<string | null> {
|
||||
const settings = await this.getUserSettings(userId);
|
||||
return settings?.ai_api_key || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if web search is enabled for user
|
||||
*/
|
||||
async isWebSearchEnabled(userId: number): Promise<boolean> {
|
||||
const settings = await this.getUserSettings(userId);
|
||||
return settings?.web_search_enabled || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Tavily API key for user
|
||||
*/
|
||||
async getTavilyApiKey(userId: number): Promise<string | null> {
|
||||
const settings = await this.getUserSettings(userId);
|
||||
return settings?.tavily_api_key || null;
|
||||
}
|
||||
}
|
||||
|
||||
export const userSettingsService = new UserSettingsService();
|
||||
@@ -89,6 +89,11 @@ export interface ApplicationDetails {
|
||||
applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM
|
||||
technischeArchitectuur?: string | null; // URL to Technical Architecture document (Attribute ID 572)
|
||||
dataCompletenessPercentage?: number; // Data completeness percentage (0-100)
|
||||
reference?: string | null; // Reference field (Enterprise Architect GUID)
|
||||
confluenceSpace?: string | null; // Confluence Space URL
|
||||
supplierTechnical?: ReferenceValue | null; // Supplier Technical
|
||||
supplierImplementation?: ReferenceValue | null; // Supplier Implementation
|
||||
supplierConsultancy?: ReferenceValue | null; // Supplier Consultancy
|
||||
}
|
||||
|
||||
// Search filters
|
||||
|
||||
Reference in New Issue
Block a user