219 lines
6.6 KiB
TypeScript
219 lines
6.6 KiB
TypeScript
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<string, unknown> | null,
|
|
};
|
|
}
|