Re_Backend/src/services/user.service.ts

358 lines
11 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';
// 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<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 }
]
}
});
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<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): 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
}
}
// 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
*/
async fetchUserFromOktaByEmail(email: string): Promise<OktaUser | null> {
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<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;
}
}