import { User as UserModel } from '../models/User'; import { Op } from 'sequelize'; import { SSOUserData } from '../types/auth.types'; // Use shared type import axios from 'axios'; // 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; }; } export class UserService { 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 } ] } }); const now = new Date(); if (existingUser) { // Update existing user - include oktaSub to ensure it's synced await existingUser.update({ email: ssoData.email, oktaSub: ssoData.oktaSub, employeeId: ssoData.employeeId || null, // Optional firstName: ssoData.firstName || null, lastName: ssoData.lastName || null, displayName: ssoData.displayName || null, department: ssoData.department || null, designation: ssoData.designation || null, phone: ssoData.phone || null, // location: (ssoData as any).location || null, // Ignored for now - schema not finalized lastLogin: now, updatedAt: now, isActive: true, // Ensure user is active after SSO login }); return existingUser; } else { // Create new user - oktaSub is required const newUser = await UserModel.create({ email: ssoData.email, oktaSub: ssoData.oktaSub, // Required employeeId: ssoData.employeeId || null, // Optional firstName: ssoData.firstName || null, lastName: ssoData.lastName || null, displayName: ssoData.displayName || null, department: ssoData.department || null, designation: ssoData.designation || null, phone: ssoData.phone || null, // location: (ssoData as any).location || null, // Ignored for now - schema not finalized isActive: true, role: 'USER', // Default role for new users lastLogin: now }); 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): 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 } } // 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 */ async fetchUserFromOktaByEmail(email: string): Promise { try { const oktaDomain = process.env.OKTA_DOMAIN; const oktaApiToken = process.env.OKTA_API_TOKEN; if (!oktaDomain || !oktaApiToken) { return null; } // Search Okta users by email (exact match) 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: OktaUser[] = response.data || []; return users.length > 0 ? users[0] : null; } catch (error: any) { console.error(`Failed to fetch user from Okta by email ${email}:`, error.message); return null; } } /** * 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; } }