erp_be/src/modules/users/users.service.js

436 lines
12 KiB
JavaScript

const bcrypt = require('bcrypt');
const prisma = require('../../config/prisma');
const env = require('../../config/env');
const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog');
const { getPagination } = require('../../utils/pagination');
const { encrypt, decrypt, blindIndex } = require('../../utils/encryption');
const { assertPlant } = require('../../utils/locations');
const { extractUserRoles } = require('../../utils/userPermissions');
const userInclude = {
user_roles: {
include: { roles: { select: { id: true, name: true } } },
},
departments: { select: { id: true, name: true } },
designations: { select: { id: true, name: true } },
plant_location: { select: { id: true, code: true, name: true } },
users_users_reporting_toTousers: { select: { id: true, full_name: true, employee_code: true } },
};
const mapUserRoles = (user) =>
extractUserRoles(user)
.map((role) => ({ id: role.id, name: role.name }))
.sort((a, b) => a.name.localeCompare(b.name));
const sanitizeUser = (user) => {
if (!user) return null;
const {
user_roles,
plant_location,
users_users_reporting_toTousers,
departments,
designations,
...rest
} = user;
delete rest.password_hash;
delete rest.mobile_index;
const roles = mapUserRoles(user);
return {
...rest,
mobile: rest.mobile ? decrypt(rest.mobile) : null,
roles,
role: roles[0] || null,
department: departments || null,
designation: designations || null,
plant: plant_location || null,
reporting_manager: users_users_reporting_toTousers || null,
};
};
const buildUsersWhere = (query) => ({
deleted_at: null,
...(query.status ? { status: query.status } : {}),
...(query.is_active !== undefined ? { is_active: query.is_active } : {}),
...(query.role_id
? {
user_roles: {
some: { role_id: BigInt(query.role_id) },
},
}
: {}),
...(query.department_id ? { department_id: BigInt(query.department_id) } : {}),
...(query.search
? {
OR: [
{ full_name: { contains: query.search, mode: 'insensitive' } },
{ email: { contains: query.search, mode: 'insensitive' } },
{ employee_code: { contains: query.search, mode: 'insensitive' } },
],
}
: {}),
});
const toInitials = (name) => {
if (!name) return '';
return name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() || '')
.join('');
};
const toUserListItem = (user) => {
const row = sanitizeUser(user);
return {
id: row.id,
full_name: row.full_name,
email: row.email,
initials: toInitials(row.full_name),
employee_code: row.employee_code,
roles: row.roles,
role: row.role,
department: row.department,
plant: row.plant,
last_login_at: row.last_login_at,
status: row.status,
is_active: row.is_active,
};
};
const assertRoleIds = async (roleIds) => {
const uniqueIds = [...new Set((roleIds || []).map((id) => BigInt(id)))];
if (uniqueIds.length === 0) throw new ApiError(422, 'At least one role is required');
const roles = await prisma.roles.findMany({
where: { id: { in: uniqueIds }, deleted_at: null, is_active: true },
});
if (roles.length !== uniqueIds.length) {
throw new ApiError(422, 'Invalid role_ids');
}
return uniqueIds;
};
const assertFk = async (payload) => {
if (payload.department_id) {
const row = await prisma.departments.findFirst({
where: { id: BigInt(payload.department_id), deleted_at: null },
});
if (!row) throw new ApiError(422, 'Invalid department_id');
}
if (payload.designation_id) {
const row = await prisma.designations.findFirst({
where: { id: BigInt(payload.designation_id), deleted_at: null },
});
if (!row) throw new ApiError(422, 'Invalid designation_id');
}
if (payload.plant_id) {
await assertPlant(payload.plant_id, 'plant_id');
}
if (payload.reporting_to) {
const row = await prisma.users.findFirst({
where: { id: BigInt(payload.reporting_to), deleted_at: null },
});
if (!row) throw new ApiError(422, 'Invalid reporting_to');
}
};
const buildUserData = async (payload, { hashPassword = false } = {}) => {
const data = { ...payload };
delete data.role_ids;
if (hashPassword && data.password) {
data.password_hash = await bcrypt.hash(data.password, env.BCRYPT_SALT_ROUNDS);
delete data.password;
} else {
delete data.password;
}
if (Object.prototype.hasOwnProperty.call(data, 'mobile')) {
if (data.mobile) {
data.mobile_index = blindIndex(data.mobile);
data.mobile = encrypt(data.mobile);
} else {
data.mobile = null;
data.mobile_index = null;
}
}
for (const key of ['department_id', 'designation_id', 'plant_id', 'reporting_to']) {
if (data[key] !== undefined && data[key] !== null) data[key] = BigInt(data[key]);
}
return data;
};
const syncUserRoles = async (tx, userId, roleIds) => {
const uniqueRoleIds = await assertRoleIds(roleIds);
await tx.user_roles.deleteMany({ where: { user_id: userId } });
await tx.user_roles.createMany({
data: uniqueRoleIds.map((role_id) => ({ user_id: userId, role_id })),
});
return uniqueRoleIds;
};
const createUser = async (payload, userId, requestId) => {
await assertFk(payload);
const roleIds = await assertRoleIds(payload.role_ids);
const existing = await prisma.users.findFirst({
where: {
deleted_at: null,
OR: [{ email: payload.email }, { employee_code: payload.employee_code }],
},
});
if (existing) throw new ApiError(409, 'User with this email or employee code already exists');
const data = await buildUserData(payload, { hashPassword: true });
data.created_by = userId ? BigInt(userId) : null;
data.updated_by = userId ? BigInt(userId) : null;
const created = await prisma.$transaction(async (tx) => {
const user = await tx.users.create({ data });
await tx.user_roles.createMany({
data: roleIds.map((role_id) => ({ user_id: user.id, role_id })),
});
return tx.users.findFirst({
where: { id: user.id },
include: userInclude,
});
});
const sanitized = sanitizeUser(created);
await auditLog({
tableName: 'users',
recordId: created.id,
action: 'CREATE',
oldValue: null,
newValue: sanitized,
userId,
requestId,
});
await auditLog({
tableName: 'user_roles',
recordId: created.id,
action: 'CREATE',
oldValue: null,
newValue: roleIds.map((id) => id.toString()),
userId,
requestId,
});
return sanitized;
};
const listUsers = async (query) => {
const { page, limit, skip } = getPagination(query);
const where = buildUsersWhere(query);
const [rows, total] = await Promise.all([
prisma.users.findMany({
where,
include: userInclude,
orderBy: { created_at: 'desc' },
skip,
take: limit,
}),
prisma.users.count({ where }),
]);
return { data: rows.map(toUserListItem), meta: { page, limit, total } };
};
const getScreenSummary = async () => {
const baseWhere = { deleted_at: null };
const [total, active, inactive, locked, rolesTotal] = await Promise.all([
prisma.users.count({ where: baseWhere }),
prisma.users.count({ where: { ...baseWhere, status: 'active' } }),
prisma.users.count({ where: { ...baseWhere, status: 'inactive' } }),
prisma.users.count({ where: { ...baseWhere, status: 'locked' } }),
prisma.roles.count({ where: { deleted_at: null } }),
]);
return {
users: { total, active, inactive, locked },
roles: { total: rolesTotal },
tabs: { users: total, roles: rolesTotal },
};
};
const getFilterOptions = async () => {
const [roles, departments] = await Promise.all([
prisma.roles.findMany({
where: { deleted_at: null, is_active: true },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
prisma.departments.findMany({
where: { deleted_at: null, is_active: true },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
]);
return {
roles,
departments,
statuses: [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'locked', label: 'Locked' },
],
};
};
const { rowsToCsv } = require('../../utils/csv');
const exportUsers = async (query) => {
const where = buildUsersWhere(query);
const rows = await prisma.users.findMany({
where,
include: userInclude,
orderBy: { full_name: 'asc' },
});
return rowsToCsv(
[
{ key: 'full_name', header: 'Full Name' },
{ key: 'email', header: 'Email' },
{ key: 'employee_code', header: 'Employee Code' },
{ key: (row) => row.roles.map((role) => role.name).join('; '), header: 'Roles' },
{ key: (row) => row.department?.name || '', header: 'Department' },
{ key: (row) => row.plant?.name || '', header: 'Plant' },
{ key: 'last_login_at', header: 'Last Login', type: 'datetime' },
{ key: 'status', header: 'Status' },
],
rows.map(toUserListItem)
);
};
const getUserById = async (id) => {
const user = await prisma.users.findFirst({
where: { id: BigInt(id), deleted_at: null },
include: userInclude,
});
if (!user) throw new ApiError(404, 'User not found');
return sanitizeUser(user);
};
const updateUser = async (id, payload, userId, requestId) => {
const existing = await prisma.users.findFirst({
where: { id: BigInt(id), deleted_at: null },
include: userInclude,
});
if (!existing) throw new ApiError(404, 'User not found');
await assertFk(payload);
if (payload.email || payload.employee_code) {
const duplicate = await prisma.users.findFirst({
where: {
deleted_at: null,
id: { not: BigInt(id) },
OR: [
...(payload.email ? [{ email: payload.email }] : []),
...(payload.employee_code ? [{ employee_code: payload.employee_code }] : []),
],
},
});
if (duplicate) throw new ApiError(409, 'User with this email or employee code already exists');
}
const data = await buildUserData(payload, { hashPassword: Boolean(payload.password) });
data.updated_by = userId ? BigInt(userId) : null;
const userIdBigInt = BigInt(id);
const previousRoleIds = (existing.user_roles || []).map((entry) => entry.role_id.toString());
const updated = await prisma.$transaction(async (tx) => {
if (payload.role_ids) {
await syncUserRoles(tx, userIdBigInt, payload.role_ids);
}
return tx.users.update({
where: { id: userIdBigInt },
data,
include: userInclude,
});
});
const sanitized = sanitizeUser(updated);
await auditLog({
tableName: 'users',
recordId: id,
action: 'UPDATE',
oldValue: sanitizeUser(existing),
newValue: sanitized,
userId,
requestId,
});
if (payload.role_ids) {
const newRoleIds = sanitized.roles.map((role) => role.id.toString());
await auditLog({
tableName: 'user_roles',
recordId: id,
action: 'UPDATE',
oldValue: previousRoleIds,
newValue: newRoleIds,
userId,
requestId,
});
}
return sanitized;
};
const deleteUser = async (id, userId, requestId) => {
const existing = await prisma.users.findFirst({
where: { id: BigInt(id), deleted_at: null },
include: userInclude,
});
if (!existing) throw new ApiError(404, 'User not found');
if (userId && String(existing.id) === String(userId)) {
throw new ApiError(400, 'You cannot delete your own account');
}
await prisma.users.update({
where: { id: BigInt(id) },
data: { deleted_at: new Date(), updated_by: userId ? BigInt(userId) : null, is_active: false },
});
await auditLog({
tableName: 'users',
recordId: id,
action: 'DELETE',
oldValue: sanitizeUser(existing),
newValue: { deleted_at: new Date() },
userId,
requestId,
});
};
module.exports = {
createUser,
listUsers,
getUserById,
updateUser,
deleteUser,
getScreenSummary,
getFilterOptions,
exportUsers,
};