Admin panel: real data - backend API, users/audit/roles/stats, frontend wired
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
434
backend/src/modules/admin/admin.service.ts
Normal file
434
backend/src/modules/admin/admin.service.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
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.findUnique({
|
||||
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.findUnique({ 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 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);
|
||||
}
|
||||
}
|
||||
|
||||
export const adminService = new AdminService();
|
||||
Reference in New Issue
Block a user