import bcrypt from 'bcryptjs'; import { Prisma } from '@prisma/client'; import { prisma } from '../../config/database'; import { AppError } from '../../middleware/error-handler'; import { getPrimaryRole } from '../../utils/roles'; import { userProvisioningService } from '../../services/user-provisioning/provisioning.service'; import { logger } from '../../utils/logger'; import type { CMUser } from '../../services/user-provisioning/provisioner.interface'; import type { CreateUserInput, UpdateUserInput, ListUsersInput } from './users.schemas'; const userSelect = { id: true, email: true, name: true, phone: true, pronouns: true, role: true, roles: true, status: true, permissions: true, createdVia: true, expiresAt: true, expireDays: true, lastLoginAt: true, emailVerified: true, createdAt: true, updatedAt: true, } satisfies Prisma.UserSelect; export const usersService = { async findAll(filters: ListUsersInput) { const { page, limit, search, role, status } = filters; const skip = (page - 1) * limit; const where: Prisma.UserWhereInput = {}; if (search) { where.OR = [ { email: { contains: search, mode: 'insensitive' } }, { name: { contains: search, mode: 'insensitive' } }, ]; } if (role) where.role = role; if (status) where.status = status; const [users, total] = await Promise.all([ prisma.user.findMany({ where, select: userSelect, skip, take: limit, orderBy: { createdAt: 'desc' }, }), prisma.user.count({ where }), ]); return { users, pagination: { page, limit, total, totalPages: Math.ceil(total / limit), }, }; }, async findById(id: string) { const user = await prisma.user.findUnique({ where: { id }, select: userSelect, }); if (!user) { throw new AppError(404, 'User not found', 'USER_NOT_FOUND'); } return user; }, async create(data: CreateUserInput) { const existing = await prisma.user.findUnique({ where: { email: data.email } }); if (existing) { throw new AppError(409, 'Email already registered', 'EMAIL_EXISTS'); } const hashedPassword = await bcrypt.hash(data.password, 12); // Compute roles array and primary role const roles = data.roles && data.roles.length > 0 ? data.roles : [data.role || 'USER']; const primaryRole = getPrimaryRole(roles as any); const user = await prisma.user.create({ data: { ...data, password: hashedPassword, role: primaryRole, roles: JSON.parse(JSON.stringify(roles)), emailVerified: true, // Admin-created users are pre-verified expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined, }, select: userSelect, }); // Fire-and-forget: auto-link to existing Contact with matching email prisma.contact.findFirst({ where: { email: { equals: data.email, mode: 'insensitive' }, userId: null, mergedIntoId: null }, }).then(async (existingContact) => { if (existingContact) { await prisma.contact.update({ where: { id: existingContact.id }, data: { userId: user.id } }); logger.info(`Auto-linked contact ${existingContact.id} to new user ${user.id}`); } }).catch(err => { logger.warn('Auto-link contact on user creation failed:', err); }); // Fire-and-forget: provision to eager services userProvisioningService.onUserCreated(toCMUser(user)).catch(err => { logger.warn('User provisioning hook (create) failed:', err); }); return user; }, async update(id: string, data: UpdateUserInput) { const existing = await prisma.user.findUnique({ where: { id } }); if (!existing) { throw new AppError(404, 'User not found', 'USER_NOT_FOUND'); } if (data.email && data.email !== existing.email) { const emailTaken = await prisma.user.findUnique({ where: { email: data.email } }); if (emailTaken) { throw new AppError(409, 'Email already in use', 'EMAIL_EXISTS'); } } const updateData: Prisma.UserUpdateInput = { ...data }; if (data.password) { updateData.password = await bcrypt.hash(data.password, 12); } if (data.expiresAt !== undefined) { updateData.expiresAt = data.expiresAt ? new Date(data.expiresAt) : null; } // Sync roles and primary role if (data.roles) { updateData.roles = JSON.parse(JSON.stringify(data.roles)); updateData.role = getPrimaryRole(data.roles as any); } else if (data.role) { // If only primary role changed, update roles array too const currentRoles = Array.isArray(existing.roles) ? existing.roles as string[] : [existing.role]; if (!currentRoles.includes(data.role)) { updateData.roles = JSON.parse(JSON.stringify([...currentRoles, data.role])); } } const user = await prisma.user.update({ where: { id }, data: updateData, select: userSelect, }); // Invalidate sessions when user is deactivated or banned const deactivatedStatuses = ['INACTIVE', 'BANNED', 'PENDING_APPROVAL', 'PENDING_VERIFICATION']; if (data.status && deactivatedStatuses.includes(data.status)) { await prisma.refreshToken.deleteMany({ where: { userId: id } }); } // Invalidate sessions when password is changed by admin if (data.password) { await prisma.refreshToken.deleteMany({ where: { userId: id } }); } // Fire-and-forget: sync changes to provisioned services userProvisioningService.onUserUpdated(toCMUser(user), data).catch(err => { logger.warn('User provisioning hook (update) failed:', err); }); return user; }, async delete(id: string) { const existing = await prisma.user.findUnique({ where: { id } }); if (!existing) { throw new AppError(404, 'User not found', 'USER_NOT_FOUND'); } // Fire-and-forget: deactivate in provisioned services BEFORE deleting const cmUser = toCMUser(existing as any); userProvisioningService.onUserDeactivated(cmUser).catch(err => { logger.warn('User provisioning hook (delete) failed:', err); }); await prisma.user.delete({ where: { id } }); }, }; /** Convert a Prisma User (or select result) to the CMUser shape needed by provisioners */ function toCMUser(user: { id: string; email: string; name: string | null; role: string; roles: unknown; status: string; permissions: unknown; }): CMUser { return { id: user.id, email: user.email, name: user.name, role: user.role, roles: user.roles, status: user.status, permissions: user.permissions as Record | null, }; }