Files
zerp/backend/src/modules/admin/admin.service.ts
Talal Sharabi 4c139429e2 feat(tenders): add Tender Management module (SRS, backend, frontend)
- SRS document: docs/SRS_TENDER_MANAGEMENT.md
- Prisma: Tender, TenderDirective models; Deal.sourceTenderId; Attachment.tenderId/tenderDirectiveId
- Backend: tenders module (CRUD, duplicate check, directives, notifications, file upload, convert-to-deal)
- Frontend: tenders list, detail, create/edit forms, directives, convert to deal, i18n (en/ar), dashboard card
- Seed: tenders permissions for admin and sales positions
- Auth: admin.service findFirst for email check (Prisma compatibility)

Made-with: Cursor
2026-03-11 16:57:40 +04:00

641 lines
18 KiB
TypeScript

import bcrypt from 'bcryptjs';
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger';
import { config } from '../../config';
import { Prisma } from '@prisma/client';
export interface UserFilters {
search?: string;
isActive?: boolean;
positionId?: string;
page?: number;
pageSize?: number;
}
export interface CreateUserData {
email: string;
username: string;
password: string;
employeeId: string;
isActive?: boolean;
}
export interface UpdateUserData {
email?: string;
username?: string;
password?: string;
employeeId?: string;
isActive?: boolean;
}
export interface AuditLogFilters {
entityType?: string;
action?: string;
userId?: string;
startDate?: string;
endDate?: string;
page?: number;
pageSize?: number;
}
class AdminService {
// ========== USERS ==========
async findAllUsers(filters: UserFilters) {
const page = filters.page || 1;
const pageSize = Math.min(filters.pageSize || 20, 100);
const skip = (page - 1) * pageSize;
const where: Prisma.UserWhereInput = {};
if (filters.isActive !== undefined) {
where.isActive = filters.isActive;
}
if (filters.positionId) {
where.employee = {
positionId: filters.positionId,
};
}
if (filters.search?.trim()) {
const search = filters.search.trim().toLowerCase();
where.OR = [
{ username: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{
employee: {
OR: [
{ firstName: { contains: search, mode: 'insensitive' } },
{ lastName: { contains: search, mode: 'insensitive' } },
],
},
},
];
}
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
skip,
take: pageSize,
include: {
employee: {
include: {
position: { select: { id: true, title: true, titleAr: true } },
department: { select: { name: true, nameAr: true } },
},
},
},
orderBy: { createdAt: 'desc' },
}),
prisma.user.count({ where }),
]);
const sanitized = users.map((u) => {
const { password: _, ...rest } = u;
return rest;
});
return {
users: sanitized,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
async findUserById(id: string) {
const user = await prisma.user.findUnique({
where: { id },
include: {
employee: {
include: {
position: { include: { permissions: true } },
department: true,
},
},
},
});
if (!user) {
throw new AppError(404, 'المستخدم غير موجود - User not found');
}
const { password: _, ...rest } = user;
return rest;
}
async createUser(data: CreateUserData, createdById: string) {
const employee = await prisma.employee.findUnique({
where: { id: data.employeeId },
});
if (!employee) {
throw new AppError(400, 'الموظف غير موجود - Employee not found');
}
if (employee.status !== 'ACTIVE') {
throw new AppError(400, 'الموظف غير نشط - Employee must be ACTIVE');
}
const existingUser = await prisma.user.findFirst({
where: { employeeId: data.employeeId },
});
if (existingUser) {
throw new AppError(400, 'هذا الموظف مرتبط بحساب مستخدم بالفعل - Employee already has a user account');
}
const emailExists = await prisma.user.findFirst({
where: { email: data.email },
});
if (emailExists) {
throw new AppError(400, 'البريد الإلكتروني مستخدم بالفعل - Email already in use');
}
const usernameExists = await prisma.user.findUnique({
where: { username: data.username },
});
if (usernameExists) {
throw new AppError(400, 'اسم المستخدم مستخدم بالفعل - Username already in use');
}
const hashedPassword = await bcrypt.hash(data.password, config.security?.bcryptRounds || 10);
const user = await prisma.user.create({
data: {
email: data.email,
username: data.username,
password: hashedPassword,
employeeId: data.employeeId,
isActive: data.isActive ?? true,
},
select: {
id: true,
email: true,
username: true,
employeeId: true,
isActive: true,
lastLogin: true,
createdAt: true,
employee: {
include: {
position: { select: { title: true, titleAr: true } },
},
},
},
});
await AuditLogger.log({
entityType: 'USER',
entityId: user.id,
action: 'CREATE',
userId: createdById,
changes: { created: { email: user.email, username: user.username } },
});
return user;
}
async updateUser(id: string, data: UpdateUserData, updatedById: string) {
const existing = await prisma.user.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'المستخدم غير موجود - User not found');
}
if (data.email && data.email !== existing.email) {
const emailExists = await prisma.user.findFirst({ where: { email: data.email } });
if (emailExists) {
throw new AppError(400, 'البريد الإلكتروني مستخدم بالفعل - Email already in use');
}
}
if (data.username && data.username !== existing.username) {
const usernameExists = await prisma.user.findUnique({ where: { username: data.username } });
if (usernameExists) {
throw new AppError(400, 'اسم المستخدم مستخدم بالفعل - Username already in use');
}
}
const updateData: Prisma.UserUpdateInput = {
...(data.email && { email: data.email }),
...(data.username && { username: data.username }),
...(data.isActive !== undefined && { isActive: data.isActive }),
...(data.employeeId !== undefined && { employeeId: data.employeeId || null }),
};
if (data.password && data.password.length >= 8) {
updateData.password = await bcrypt.hash(data.password, config.security?.bcryptRounds || 10);
}
const user = await prisma.user.update({
where: { id },
data: updateData,
include: {
employee: {
include: {
position: { select: { title: true, titleAr: true } },
},
},
},
});
const { password: _, ...sanitized } = user;
await AuditLogger.log({
entityType: 'USER',
entityId: id,
action: 'UPDATE',
userId: updatedById,
changes: { before: existing, after: sanitized },
});
return sanitized;
}
async toggleUserActive(id: string, userId: string) {
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
throw new AppError(404, 'المستخدم غير موجود - User not found');
}
const updated = await prisma.user.update({
where: { id },
data: { isActive: !user.isActive },
include: {
employee: {
include: {
position: { select: { title: true, titleAr: true } },
},
},
},
});
const { password: _, ...sanitized } = updated;
await AuditLogger.log({
entityType: 'USER',
entityId: id,
action: user.isActive ? 'DEACTIVATE' : 'ACTIVATE',
userId,
changes: { isActive: updated.isActive },
});
return sanitized;
}
async deleteUser(id: string, deletedById: string) {
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
throw new AppError(404, 'المستخدم غير موجود - User not found');
}
await prisma.user.update({
where: { id },
data: { isActive: false },
});
await AuditLogger.log({
entityType: 'USER',
entityId: id,
action: 'DELETE',
userId: deletedById,
changes: { softDeleted: true, email: user.email },
});
return { success: true, message: 'User deactivated successfully' };
}
// ========== AUDIT LOGS ==========
async getAuditLogs(filters: AuditLogFilters) {
const page = filters.page || 1;
const pageSize = Math.min(filters.pageSize || 20, 100);
const skip = (page - 1) * pageSize;
const where: Prisma.AuditLogWhereInput = {};
if (filters.entityType) where.entityType = filters.entityType;
if (filters.action) where.action = filters.action;
if (filters.userId) where.userId = filters.userId;
if (filters.startDate || filters.endDate) {
where.createdAt = {};
if (filters.startDate) where.createdAt.gte = new Date(filters.startDate);
if (filters.endDate) where.createdAt.lte = new Date(filters.endDate);
}
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({
where,
skip,
take: pageSize,
include: {
user: {
select: { id: true, username: true, email: true },
},
},
orderBy: { createdAt: 'desc' },
}),
prisma.auditLog.count({ where }),
]);
return {
logs,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
// ========== STATS ==========
async getStats() {
const today = new Date();
today.setHours(0, 0, 0, 0);
const [totalUsers, activeUsers, inactiveUsers, loginsToday] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { isActive: true } }),
prisma.user.count({ where: { isActive: false } }),
prisma.user.count({
where: { lastLogin: { gte: today } },
}),
]);
return {
totalUsers,
activeUsers,
inactiveUsers,
loginsToday,
};
}
// ========== POSITIONS (ROLES) ==========
async getPositions() {
const positions = await prisma.position.findMany({
where: { isActive: true },
include: {
department: { select: { name: true, nameAr: true } },
permissions: true,
_count: {
select: {
employees: true,
},
},
},
orderBy: { level: 'asc' },
});
const withUserCount = await Promise.all(
positions.map(async (p) => {
const usersCount = await prisma.user.count({
where: {
employee: { positionId: p.id },
isActive: true,
},
});
return { ...p, usersCount };
})
);
return withUserCount;
}
async createPosition(data: {
title: string;
titleAr?: string;
code: string;
departmentId: string;
level?: number;
description?: string;
isActive?: boolean;
}) {
const existing = await prisma.position.findUnique({
where: { code: data.code },
});
if (existing) {
throw new AppError(400, 'كود الدور مستخدم - Position code already exists');
}
const dept = await prisma.department.findUnique({
where: { id: data.departmentId },
});
if (!dept) {
throw new AppError(400, 'القسم غير موجود - Department not found');
}
return prisma.position.create({
data: {
title: data.title,
titleAr: data.titleAr,
code: data.code.trim().toUpperCase().replace(/\s+/g, '_'),
departmentId: data.departmentId,
level: data.level ?? 5,
description: data.description,
isActive: data.isActive ?? true,
},
include: {
department: { select: { name: true, nameAr: true } },
permissions: true,
},
});
}
async updatePosition(
positionId: string,
data: {
title?: string;
titleAr?: string;
code?: string;
departmentId?: string;
level?: number;
description?: string;
isActive?: boolean;
}
) {
const position = await prisma.position.findUnique({
where: { id: positionId },
});
if (!position) {
throw new AppError(404, 'الدور غير موجود - Position not found');
}
if (data.code && data.code !== position.code) {
const existing = await prisma.position.findUnique({
where: { code: data.code },
});
if (existing) {
throw new AppError(400, 'كود الدور مستخدم - Position code already exists');
}
}
if (data.departmentId && data.departmentId !== position.departmentId) {
const dept = await prisma.department.findUnique({
where: { id: data.departmentId },
});
if (!dept) {
throw new AppError(400, 'القسم غير موجود - Department not found');
}
}
const updateData: Record<string, any> = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.titleAr !== undefined) updateData.titleAr = data.titleAr;
if (data.code !== undefined) updateData.code = data.code.trim().toUpperCase().replace(/\s+/g, '_');
if (data.departmentId !== undefined) updateData.departmentId = data.departmentId;
if (data.level !== undefined) updateData.level = data.level;
if (data.description !== undefined) updateData.description = data.description;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
return prisma.position.update({
where: { id: positionId },
data: updateData,
include: {
department: { select: { name: true, nameAr: true } },
permissions: true,
},
});
}
async updatePositionPermissions(positionId: string, permissions: Array<{ module: string; resource: string; actions: string[] }>) {
const position = await prisma.position.findUnique({ where: { id: positionId } });
if (!position) {
throw new AppError(404, 'الدور غير موجود - Position not found');
}
await prisma.positionPermission.deleteMany({
where: { positionId },
});
if (permissions.length > 0) {
await prisma.positionPermission.createMany({
data: permissions.map((p) => ({
positionId,
module: p.module,
resource: p.resource,
actions: p.actions,
})),
});
}
return this.getPositions().then((pos) => pos.find((p) => p.id === positionId) || position);
}
// ========== PERMISSION GROUPS (Phase 3 - optional roles for multi-group) ==========
async getPermissionGroups() {
return prisma.role.findMany({
where: { isActive: true },
include: {
permissions: true,
_count: { select: { userRoles: true } },
},
orderBy: { name: 'asc' },
});
}
async createPermissionGroup(data: { name: string; nameAr?: string; description?: string }) {
const existing = await prisma.role.findUnique({ where: { name: data.name } });
if (existing) {
throw new AppError(400, 'اسم المجموعة مستخدم - Group name already exists');
}
return prisma.role.create({
data: {
name: data.name,
nameAr: data.nameAr,
description: data.description,
},
include: { permissions: true },
});
}
async updatePermissionGroup(
id: string,
data: { name?: string; nameAr?: string; description?: string; isActive?: boolean }
) {
const role = await prisma.role.findUnique({ where: { id } });
if (!role) {
throw new AppError(404, 'المجموعة غير موجودة - Group not found');
}
if (data.name && data.name !== role.name) {
const existing = await prisma.role.findUnique({ where: { name: data.name } });
if (existing) {
throw new AppError(400, 'اسم المجموعة مستخدم - Group name already exists');
}
}
return prisma.role.update({
where: { id },
data,
include: { permissions: true },
});
}
async updatePermissionGroupPermissions(
roleId: string,
permissions: Array<{ module: string; resource: string; actions: string[] }>
) {
await prisma.rolePermission.deleteMany({ where: { roleId } });
if (permissions.length > 0) {
await prisma.rolePermission.createMany({
data: permissions.map((p) => ({
roleId,
module: p.module,
resource: p.resource,
actions: p.actions,
})),
});
}
return prisma.role.findUnique({
where: { id: roleId },
include: { permissions: true },
});
}
async getUserRoles(userId: string) {
return prisma.userRole.findMany({
where: { userId },
include: {
role: { include: { permissions: true } },
},
});
}
async assignUserRole(userId: string, roleId: string) {
const [user, role] = await Promise.all([
prisma.user.findUnique({ where: { id: userId } }),
prisma.role.findFirst({ where: { id: roleId, isActive: true } }),
]);
if (!user) throw new AppError(404, 'المستخدم غير موجود - User not found');
if (!role) throw new AppError(404, 'المجموعة غير موجودة - Group not found');
const existing = await prisma.userRole.findUnique({
where: { userId_roleId: { userId, roleId } },
});
if (existing) {
throw new AppError(400, 'المستخدم منتمي بالفعل لهذه المجموعة - User already in group');
}
return prisma.userRole.create({
data: { userId, roleId },
include: { role: true },
});
}
async removeUserRole(userId: string, roleId: string) {
const deleted = await prisma.userRole.deleteMany({
where: { userId, roleId },
});
if (deleted.count === 0) {
throw new AppError(404, 'لم يتم العثور على الانتماء - User not in group');
}
return { success: true };
}
}
export const adminService = new AdminService();