Re_Backend/src/services/user.service.ts

497 lines
16 KiB
TypeScript

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<UserModel> {
// 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<UserModel | null> {
return await UserModel.findByPk(userId);
}
async getUserByEmployeeId(employeeId: string): Promise<UserModel | null> {
return await UserModel.findOne({ where: { employeeId } });
}
async getAllUsers(): Promise<UserModel[]> {
return await UserModel.findAll({
order: [['createdAt', 'DESC']]
});
}
async searchUsers(query: string, limit: number = 10, excludeUserId?: string, source: 'local' | 'okta' | 'default' = 'default'): Promise<any[]> {
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<UserModel[]> {
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<OktaUser | null> {
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<SSOUserData | null> {
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<OktaUser[]> {
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<OktaUser | null> {
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<UserModel> {
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;
}
}