- 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
641 lines
18 KiB
TypeScript
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();
|