Admin panel: real data - backend API, users/audit/roles/stats, frontend wired

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Talal Sharabi
2026-02-19 15:40:34 +04:00
parent 680ba3871e
commit 842678674b
11 changed files with 2159 additions and 809 deletions

View File

@@ -88,6 +88,16 @@ async function main() {
});
}
// Admin permission for GM
await prisma.positionPermission.create({
data: {
positionId: gmPosition.id,
module: 'admin',
resource: '*',
actions: ['*'],
},
});
// Create Permissions for Sales Manager
await prisma.positionPermission.createMany({
data: [

View File

@@ -0,0 +1,41 @@
/**
* Add admin permission for GM position.
* Run this for existing databases where seed was run before admin module existed:
* npx ts-node scripts/add-admin-permission.ts
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const gmPosition = await prisma.position.findFirst({ where: { code: 'GM' } });
if (!gmPosition) {
console.log('GM position not found. Run full seed first.');
process.exit(1);
}
const existing = await prisma.positionPermission.findFirst({
where: { positionId: gmPosition.id, module: 'admin' },
});
if (existing) {
console.log('Admin permission already exists for GM.');
process.exit(0);
}
await prisma.positionPermission.create({
data: {
positionId: gmPosition.id,
module: 'admin',
resource: '*',
actions: ['*'],
},
});
console.log('Admin permission added for GM position.');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,150 @@
import { Response, NextFunction } from 'express';
import { adminService } from './admin.service';
import { AuthRequest } from '../../shared/middleware/auth';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
class AdminController {
// ========== USERS ==========
async findAllUsers(req: AuthRequest, res: Response, next: NextFunction) {
try {
const filters = {
search: req.query.search as string | undefined,
isActive: req.query.status === 'active' ? true : req.query.status === 'inactive' ? false : undefined,
positionId: req.query.positionId as string | undefined,
page: parseInt(req.query.page as string) || 1,
pageSize: parseInt(req.query.pageSize as string) || 20,
};
const result = await adminService.findAllUsers(filters);
res.json(ResponseFormatter.paginated(result.users, result.total, result.page, result.pageSize));
} catch (error) {
next(error);
}
}
async findUserById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const user = await adminService.findUserById(req.params.id);
res.json(ResponseFormatter.success(user));
} catch (error) {
next(error);
}
}
async createUser(req: AuthRequest, res: Response, next: NextFunction) {
try {
const userId = req.user!.id;
const user = await adminService.createUser(
{
email: req.body.email,
username: req.body.username,
password: req.body.password,
employeeId: req.body.employeeId,
isActive: req.body.isActive ?? true,
},
userId
);
res.status(201).json(ResponseFormatter.success(user));
} catch (error) {
next(error);
}
}
async updateUser(req: AuthRequest, res: Response, next: NextFunction) {
try {
const userId = req.user!.id;
const user = await adminService.updateUser(
req.params.id,
{
email: req.body.email,
username: req.body.username,
password: req.body.password,
employeeId: req.body.employeeId,
isActive: req.body.isActive,
},
userId
);
res.json(ResponseFormatter.success(user));
} catch (error) {
next(error);
}
}
async toggleUserActive(req: AuthRequest, res: Response, next: NextFunction) {
try {
const userId = req.user!.id;
const user = await adminService.toggleUserActive(req.params.id, userId);
res.json(ResponseFormatter.success(user));
} catch (error) {
next(error);
}
}
async deleteUser(req: AuthRequest, res: Response, next: NextFunction) {
try {
const userId = req.user!.id;
await adminService.deleteUser(req.params.id, userId);
res.json(ResponseFormatter.success(null, 'User deactivated successfully'));
} catch (error) {
next(error);
}
}
// ========== AUDIT LOGS ==========
async getAuditLogs(req: AuthRequest, res: Response, next: NextFunction) {
try {
const filters = {
entityType: req.query.entityType as string | undefined,
action: req.query.action as string | undefined,
userId: req.query.userId as string | undefined,
startDate: req.query.startDate as string | undefined,
endDate: req.query.endDate as string | undefined,
page: parseInt(req.query.page as string) || 1,
pageSize: parseInt(req.query.pageSize as string) || 20,
};
const result = await adminService.getAuditLogs(filters);
res.json(
ResponseFormatter.paginated(result.logs, result.total, result.page, result.pageSize)
);
} catch (error) {
next(error);
}
}
// ========== STATS ==========
async getStats(req: AuthRequest, res: Response, next: NextFunction) {
try {
const stats = await adminService.getStats();
res.json(ResponseFormatter.success(stats));
} catch (error) {
next(error);
}
}
// ========== POSITIONS (ROLES) ==========
async getPositions(req: AuthRequest, res: Response, next: NextFunction) {
try {
const positions = await adminService.getPositions();
res.json(ResponseFormatter.success(positions));
} catch (error) {
next(error);
}
}
async updatePositionPermissions(req: AuthRequest, res: Response, next: NextFunction) {
try {
const position = await adminService.updatePositionPermissions(
req.params.id,
req.body.permissions
);
res.json(ResponseFormatter.success(position));
} catch (error) {
next(error);
}
}
}
export const adminController = new AdminController();

View File

@@ -0,0 +1,103 @@
import { Router } from 'express';
import { body, param } from 'express-validator';
import { adminController } from './admin.controller';
import { authenticate, authorize } from '../../shared/middleware/auth';
import { validate } from '../../shared/middleware/validation';
const router = Router();
router.use(authenticate);
// ========== USERS ==========
router.get(
'/users',
authorize('admin', 'users', 'read'),
adminController.findAllUsers
);
router.get(
'/users/:id',
authorize('admin', 'users', 'read'),
param('id').isUUID(),
validate,
adminController.findUserById
);
router.post(
'/users',
authorize('admin', 'users', 'create'),
[
body('email').isEmail(),
body('username').notEmpty().trim(),
body('password').isLength({ min: 8 }),
body('employeeId').isUUID(),
],
validate,
adminController.createUser
);
router.put(
'/users/:id',
authorize('admin', 'users', 'update'),
[
param('id').isUUID(),
body('email').optional().isEmail(),
body('username').optional().notEmpty().trim(),
body('password').optional().isLength({ min: 8 }),
],
validate,
adminController.updateUser
);
router.patch(
'/users/:id/toggle-active',
authorize('admin', 'users', 'update'),
param('id').isUUID(),
validate,
adminController.toggleUserActive
);
router.delete(
'/users/:id',
authorize('admin', 'users', 'delete'),
param('id').isUUID(),
validate,
adminController.deleteUser
);
// ========== AUDIT LOGS ==========
router.get(
'/audit-logs',
authorize('admin', 'audit-logs', 'read'),
adminController.getAuditLogs
);
// ========== STATS ==========
router.get(
'/stats',
authorize('admin', 'stats', 'read'),
adminController.getStats
);
// ========== POSITIONS (ROLES) ==========
router.get(
'/positions',
authorize('admin', 'roles', 'read'),
adminController.getPositions
);
router.put(
'/positions/:id/permissions',
authorize('admin', 'roles', 'update'),
[
param('id').isUUID(),
body('permissions').isArray(),
],
validate,
adminController.updatePositionPermissions
);
export default router;

View 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();

View File

@@ -1,4 +1,5 @@
import { Router } from 'express';
import adminRoutes from '../modules/admin/admin.routes';
import authRoutes from '../modules/auth/auth.routes';
import contactsRoutes from '../modules/contacts/contacts.routes';
import crmRoutes from '../modules/crm/crm.routes';
@@ -10,6 +11,7 @@ import marketingRoutes from '../modules/marketing/marketing.routes';
const router = Router();
// Module routes
router.use('/admin', adminRoutes);
router.use('/auth', authRoutes);
router.use('/contacts', contactsRoutes);
router.use('/crm', crmRoutes);