import { User as UserModel } from '../models/User'; import { Op } from 'sequelize'; import { SSOUserData } from '../types/auth.types'; // Use shared type import axios from 'axios'; import logger from '../utils/logger'; // Using UserModel type directly - interface removed to avoid duplication interface OktaUser { id: string; status: string; profile: { firstName?: string; lastName?: string; displayName?: string; email: string; login: string; department?: string; mobilePhone?: string; [key: string]: any; // Allow any additional profile fields }; } /** * Extract full user data from Okta Users API response (centralized extraction) * This ensures consistent field mapping across all user creation/update operations */ function extractOktaUserData(oktaUserResponse: any): SSOUserData | null { try { const profile = oktaUserResponse.profile || {}; const userData: SSOUserData = { oktaSub: oktaUserResponse.id || '', email: profile.email || profile.login || '', employeeId: profile.employeeID || profile.employeeId || profile.employee_id || undefined, firstName: profile.firstName || undefined, lastName: profile.lastName || undefined, displayName: profile.displayName || undefined, department: profile.department || undefined, designation: profile.title || profile.designation || undefined, phone: profile.mobilePhone || profile.phone || profile.phoneNumber || undefined, manager: profile.manager || undefined, // Manager name from Okta jobTitle: profile.title || undefined, postalAddress: profile.postalAddress || undefined, mobilePhone: profile.mobilePhone || undefined, secondEmail: profile.secondEmail || profile.second_email || undefined, adGroups: Array.isArray(profile.memberOf) ? profile.memberOf : undefined, }; // Validate required fields if (!userData.oktaSub || !userData.email) { return null; } return userData; } catch (error) { return null; } } export class UserService { /** * Build a consistent user payload for create/update from SSO data. * @param isUpdate - If true, excludes email from payload (email should never be updated) */ private buildUserPayload(ssoData: SSOUserData, existingRole?: string, isUpdate: boolean = false) { const now = new Date(); const payload: any = { oktaSub: ssoData.oktaSub, employeeId: ssoData.employeeId || null, firstName: ssoData.firstName || null, lastName: ssoData.lastName || null, displayName: ssoData.displayName || null, department: ssoData.department || null, designation: ssoData.designation || null, phone: ssoData.phone || null, manager: ssoData.manager || null, jobTitle: ssoData.designation || ssoData.jobTitle || null, postalAddress: ssoData.postalAddress || null, mobilePhone: ssoData.mobilePhone || null, secondEmail: ssoData.secondEmail || null, adGroups: ssoData.adGroups || null, lastLogin: now, updatedAt: now, isActive: ssoData.isActive ?? true, role: (ssoData.role as any) || existingRole || 'USER', }; // Only include email for new users (never update email for existing users) if (!isUpdate) { payload.email = ssoData.email; } return payload; } async createOrUpdateUser(ssoData: SSOUserData): Promise { // Validate required fields if (!ssoData.email || !ssoData.oktaSub) { throw new Error('Email and Okta sub are required'); } // Check if user exists by email (primary identifier) or oktaSub const existingUser = await UserModel.findOne({ where: { [Op.or]: [ { email: ssoData.email }, { oktaSub: ssoData.oktaSub } ] } }); if (existingUser) { // Update existing user - DO NOT update email (crucial identifier) const updatePayload = this.buildUserPayload(ssoData, existingUser.role, true); // isUpdate = true await existingUser.update(updatePayload); return existingUser; } else { // Create new user - oktaSub is required, email is included const createPayload = this.buildUserPayload(ssoData, 'USER', false); // isUpdate = false const newUser = await UserModel.create(createPayload); return newUser; } } async getUserById(userId: string): Promise { return await UserModel.findByPk(userId); } async getUserByEmployeeId(employeeId: string): Promise { return await UserModel.findOne({ where: { employeeId } }); } async getAllUsers(): Promise { return await UserModel.findAll({ order: [['createdAt', 'DESC']] }); } async searchUsers(query: string, limit: number = 10, excludeUserId?: string, source: 'local' | 'okta' | 'default' = 'default'): Promise { const q = (query || '').trim(); if (!q) { return []; } // Get the current user's email to exclude them from results let excludeEmail: string | undefined; if (excludeUserId) { try { const currentUser = await UserModel.findByPk(excludeUserId); if (currentUser) { excludeEmail = (currentUser as any).email?.toLowerCase(); } } catch (err) { // Ignore error - filtering will still work by userId for local search } } // If source is strictly 'local', skip Okta and search DB directly if (source === 'local') { return await this.searchUsersLocal(q, limit, excludeUserId); } // Search Okta users try { const oktaDomain = process.env.OKTA_DOMAIN; const oktaApiToken = process.env.OKTA_API_TOKEN; if (!oktaDomain || !oktaApiToken) { return await this.searchUsersLocal(q, limit, excludeUserId); } const response = await axios.get(`${oktaDomain}/api/v1/users`, { params: { q, limit: Math.min(limit, 50) }, headers: { 'Authorization': `SSWS ${oktaApiToken}`, 'Accept': 'application/json' }, timeout: 5000 }); const oktaUsers: OktaUser[] = response.data || []; // Transform Okta users to our format return oktaUsers .filter(u => { // Filter out inactive users if (u.status !== 'ACTIVE') return false; // Filter out current user by Okta ID or email if (excludeUserId && u.id === excludeUserId) return false; if (excludeEmail) { const userEmail = (u.profile.email || u.profile.login || '').toLowerCase(); if (userEmail === excludeEmail) return false; } return true; }) .map(u => ({ userId: u.id, oktaSub: u.id, email: u.profile.email || u.profile.login, displayName: u.profile.displayName || `${u.profile.firstName || ''} ${u.profile.lastName || ''}`.trim(), firstName: u.profile.firstName, lastName: u.profile.lastName, department: u.profile.department, phone: u.profile.mobilePhone, isActive: true })); } catch (error: any) { return await this.searchUsersLocal(q, limit, excludeUserId); } } /** * Fallback: Search users in local database */ private async searchUsersLocal(query: string, limit: number = 10, excludeUserId?: string): Promise { const q = (query || '').trim(); if (!q) { return []; } const like = `%${q}%`; const orConds = [ { email: { [Op.iLike as any]: like } as any }, { displayName: { [Op.iLike as any]: like } as any }, { firstName: { [Op.iLike as any]: like } as any }, { lastName: { [Op.iLike as any]: like } as any }, ]; const where: any = { [Op.or]: orConds }; if (excludeUserId) { where.userId = { [Op.ne]: excludeUserId } as any; } return await UserModel.findAll({ where, order: [['displayName', 'ASC']], limit: Math.min(Math.max(limit || 10, 1), 50), }); } /** * Fetch a user directly from Okta by their Okta ID * Used when we have an Okta ID and need to get user details */ async fetchUserFromOktaById(oktaId: string): Promise { try { const oktaDomain = process.env.OKTA_DOMAIN; const oktaApiToken = process.env.OKTA_API_TOKEN; if (!oktaDomain || !oktaApiToken) { return null; } const response = await axios.get(`${oktaDomain}/api/v1/users/${oktaId}`, { headers: { 'Authorization': `SSWS ${oktaApiToken}`, 'Accept': 'application/json' }, timeout: 5000 }); return response.data as OktaUser; } catch (error: any) { if (error.response?.status === 404) { // User not found in Okta return null; } throw error; } } /** * Fetch user from Okta by email and extract full profile data * Returns SSOUserData with all fields including manager, jobTitle, etc. */ async fetchAndExtractOktaUserByEmail(email: string): Promise { try { const oktaDomain = process.env.OKTA_DOMAIN; const oktaApiToken = process.env.OKTA_API_TOKEN; if (!oktaDomain || !oktaApiToken) { return null; } // Try to fetch by email directly first (more reliable) try { const directResponse = await axios.get(`${oktaDomain}/api/v1/users/${encodeURIComponent(email)}`, { headers: { 'Authorization': `SSWS ${oktaApiToken}`, 'Accept': 'application/json' }, timeout: 5000, validateStatus: (status) => status < 500 }); if (directResponse.status === 200 && directResponse.data) { return extractOktaUserData(directResponse.data); } } catch (directError) { // Fall through to search method } // Fallback: Search Okta users by email const response = await axios.get(`${oktaDomain}/api/v1/users`, { params: { search: `profile.email eq "${email}"`, limit: 1 }, headers: { 'Authorization': `SSWS ${oktaApiToken}`, 'Accept': 'application/json' }, timeout: 5000 }); const users: any[] = response.data || []; if (users.length > 0) { return extractOktaUserData(users[0]); } return null; } catch (error: any) { console.error(`Failed to fetch user from Okta by email ${email}:`, error.message); return null; } } /** * Search users in Okta by displayName * Uses Okta search API: /api/v1/users?search=profile.displayName eq "displayName" * @param displayName - Display name to search for * @returns Array of matching users from Okta */ async searchOktaByDisplayName(displayName: string): Promise { try { const oktaDomain = process.env.OKTA_DOMAIN; const oktaApiToken = process.env.OKTA_API_TOKEN; if (!oktaDomain || !oktaApiToken) { logger.warn('[UserService] Okta not configured, returning empty array for displayName search'); return []; } // Search Okta users by displayName const response = await axios.get(`${oktaDomain}/api/v1/users`, { params: { search: `profile.displayName eq "${displayName}"`, limit: 50 }, headers: { 'Authorization': `SSWS ${oktaApiToken}`, 'Accept': 'application/json' }, timeout: 5000 }); const oktaUsers: OktaUser[] = response.data || []; // Filter only active users return oktaUsers.filter(u => u.status === 'ACTIVE'); } catch (error: any) { logger.error(`[UserService] Error searching Okta by displayName "${displayName}":`, error.message); return []; } } /** * Fetch user from Okta by email (legacy method, kept for backward compatibility) * @deprecated Use fetchAndExtractOktaUserByEmail instead for full profile extraction */ async fetchUserFromOktaByEmail(email: string): Promise { const userData = await this.fetchAndExtractOktaUserByEmail(email); if (!userData) return null; // Return in legacy format for backward compatibility return { id: userData.oktaSub, status: 'ACTIVE', profile: { email: userData.email, login: userData.email, firstName: userData.firstName, lastName: userData.lastName, displayName: userData.displayName, department: userData.department, mobilePhone: userData.mobilePhone, } }; } /** * Ensure user exists in database (create if not exists) * Used when tagging users from Okta search results or when only email is provided * * @param oktaUserData - Can be just { email } or full user data */ async ensureUserExists(oktaUserData: { userId?: string; email: string; displayName?: string; firstName?: string; lastName?: string; department?: string; phone?: string; designation?: string; jobTitle?: string; manager?: string; employeeId?: string; employeeNumber?: string; secondEmail?: string; mobilePhone?: string; location?: string; }): Promise { const email = oktaUserData.email.toLowerCase(); // Check if user already exists in database let user = await UserModel.findOne({ where: { [Op.or]: [ { email }, ...(oktaUserData.userId ? [{ oktaSub: oktaUserData.userId }] : []) ] } }); if (user) { // Update existing user with latest info from Okta (if provided) const updateData: any = { email, isActive: true, updatedAt: new Date() }; if (oktaUserData.userId) updateData.oktaSub = oktaUserData.userId; if (oktaUserData.firstName) updateData.firstName = oktaUserData.firstName; if (oktaUserData.lastName) updateData.lastName = oktaUserData.lastName; if (oktaUserData.displayName) updateData.displayName = oktaUserData.displayName; if (oktaUserData.department) updateData.department = oktaUserData.department; if (oktaUserData.phone) updateData.phone = oktaUserData.phone; if (oktaUserData.designation) updateData.designation = oktaUserData.designation; if (oktaUserData.employeeId) updateData.employeeId = oktaUserData.employeeId; await user.update(updateData); return user; } // User not found in DB - try to fetch from Okta if (!oktaUserData.userId) { const oktaUser = await this.fetchUserFromOktaByEmail(email); if (oktaUser) { // Found in Okta - create with Okta data user = await UserModel.create({ oktaSub: oktaUser.id, email, employeeId: null, firstName: oktaUser.profile.firstName || null, lastName: oktaUser.profile.lastName || null, displayName: oktaUser.profile.displayName || `${oktaUser.profile.firstName || ''} ${oktaUser.profile.lastName || ''}`.trim() || email.split('@')[0], department: oktaUser.profile.department || null, designation: null, phone: oktaUser.profile.mobilePhone || null, isActive: oktaUser.status === 'ACTIVE', role: 'USER', lastLogin: undefined, createdAt: new Date(), updatedAt: new Date() }); return user; } else { // Not found in Okta either throw new Error(`User with email '${email}' not found in organization directory`); } } // Create new user with provided data user = await UserModel.create({ oktaSub: oktaUserData.userId, email, employeeId: oktaUserData.employeeId || null, firstName: oktaUserData.firstName || null, lastName: oktaUserData.lastName || null, displayName: oktaUserData.displayName || email.split('@')[0], department: oktaUserData.department || null, designation: oktaUserData.designation || oktaUserData.jobTitle || null, phone: oktaUserData.phone || oktaUserData.mobilePhone || null, isActive: true, role: 'USER', lastLogin: undefined, createdAt: new Date(), updatedAt: new Date() }); return user; } }