notification process

This commit is contained in:
Aya
2026-04-19 15:16:45 +03:00
parent 417a5ac661
commit e262d8c09c
13 changed files with 1148 additions and 45 deletions

View File

@@ -1,6 +1,7 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger';
import { notificationsService } from '../notifications/notifications.service';
class HRService {
// ========== EMPLOYEES ==========
@@ -352,15 +353,40 @@ class HRService {
},
});
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LEAVE',
entityId: leave.id,
action: 'CREATE',
userId,
});
const employeeFullName = `${leave.employee.firstName} ${leave.employee.lastName}`;
await notificationsService.notifyApprovalRecipients({
resource: 'leave_requests',
fallbackEmployeeId: leave.employeeId,
fallbackToManager: true,
type: 'LEAVE_REQUEST_SUBMITTED',
title: 'طلب إجازة جديد بانتظار الموافقة',
message: `قام الموظف ${employeeFullName} بإرسال طلب إجازة جديد.`,
entityType: 'LEAVE',
entityId: leave.id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: leave.employeeId,
type: 'LEAVE_REQUEST_CREATED',
title: 'تم إرسال طلب الإجازة',
message: 'تم إرسال طلب الإجازة الخاص بك بنجاح وهو الآن بانتظار المراجعة.',
entityType: 'LEAVE',
entityId: leave.id,
excludeUserIds: [],
});
return leave;
}
async approveLeave(id: string, approvedBy: string, userId: string) {
const leave = await prisma.leave.update({
where: { id },
@@ -378,16 +404,25 @@ class HRService {
const year = new Date(leave.startDate).getFullYear();
await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days);
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LEAVE',
entityId: leave.id,
action: 'APPROVE',
userId,
});
await notificationsService.notifyEmployeeUser({
employeeId: leave.employeeId,
type: 'LEAVE_REQUEST_APPROVED',
title: 'تمت الموافقة على طلب الإجازة',
message: 'تمت الموافقة على طلب الإجازة الخاص بك.',
entityType: 'LEAVE',
entityId: leave.id,
excludeUserIds: [userId],
});
return leave;
}
async rejectLeave(id: string, rejectedReason: string, userId: string) {
const leave = await prisma.leave.findUnique({ where: { id } });
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
@@ -403,13 +438,26 @@ class HRService {
include: { employee: true },
});
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LEAVE',
entityId: id,
action: 'REJECT',
userId,
reason: rejectedReason,
});
await notificationsService.notifyEmployeeUser({
employeeId: updated.employeeId,
type: 'LEAVE_REQUEST_REJECTED',
title: 'تم رفض طلب الإجازة',
message: rejectedReason
? `تم رفض طلب الإجازة الخاص بك. السبب: ${rejectedReason}`
: 'تم رفض طلب الإجازة الخاص بك.',
entityType: 'LEAVE',
entityId: updated.id,
excludeUserIds: [userId],
});
return updated;
}
@@ -566,13 +614,23 @@ async findManagedLeaves(status?: string) {
const year = new Date(updated.startDate).getFullYear();
await this.updateLeaveEntitlementUsed(updated.employeeId, year, updated.leaveType, updated.days);
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LEAVE',
entityId: updated.id,
action: 'MANAGER_APPROVE',
userId,
});
await notificationsService.notifyEmployeeUser({
employeeId: updated.employeeId,
type: 'LEAVE_REQUEST_APPROVED',
title: 'تمت الموافقة على طلب الإجازة',
message: 'تمت الموافقة على طلب الإجازة الخاص بك من قبل المدير المباشر.',
entityType: 'LEAVE',
entityId: updated.id,
excludeUserIds: [userId],
});
return updated;
}
@@ -610,7 +668,7 @@ async findManagedLeaves(status?: string) {
},
});
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LEAVE',
entityId: updated.id,
action: 'MANAGER_REJECT',
@@ -618,6 +676,18 @@ async findManagedLeaves(status?: string) {
reason: rejectedReason,
});
await notificationsService.notifyEmployeeUser({
employeeId: updated.employeeId,
type: 'LEAVE_REQUEST_REJECTED',
title: 'تم رفض طلب الإجازة',
message: rejectedReason
? `تم رفض طلب الإجازة الخاص بك من قبل المدير المباشر. السبب: ${rejectedReason}`
: 'تم رفض طلب الإجازة الخاص بك من قبل المدير المباشر.',
entityType: 'LEAVE',
entityId: updated.id,
excludeUserIds: [userId],
});
return updated;
}
@@ -843,7 +913,30 @@ private isSystemAdminUser(user: any) {
include: { employee: true },
});
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
const employeeFullName = `${loan.employee.firstName} ${loan.employee.lastName}`;
await notificationsService.notifyApprovalRecipients({
resource: 'loan_requests',
type: 'LOAN_REQUEST_SUBMITTED',
title: 'طلب قرض جديد بانتظار الموافقة',
message: `قام الموظف ${employeeFullName} بإرسال طلب قرض جديد برقم ${loan.loanNumber}.`,
entityType: 'LOAN',
entityId: loan.id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: loan.employeeId,
type: 'LOAN_REQUEST_CREATED',
title: 'تم إرسال طلب القرض',
message: `تم إرسال طلب القرض الخاص بك برقم ${loan.loanNumber} وهو الآن بانتظار المراجعة.`,
entityType: 'LOAN',
entityId: loan.id,
excludeUserIds: [],
});
return loan;
}
@@ -899,13 +992,36 @@ private isSystemAdminUser(user: any) {
},
});
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LOAN',
entityId: id,
action: 'HR_APPROVE_FORWARD_TO_ADMIN',
userId,
});
const fullLoan = await this.findLoanById(id);
const employeeFullName = `${fullLoan.employee.firstName} ${fullLoan.employee.lastName}`;
await notificationsService.notifyApprovalRecipients({
resource: 'loan_requests',
type: 'LOAN_REQUEST_PENDING_ADMIN',
title: 'طلب قرض محال إلى مدير النظام',
message: `تمت إحالة طلب القرض رقم ${fullLoan.loanNumber} الخاص بالموظف ${employeeFullName} إلى مدير النظام لاعتماده النهائي.`,
entityType: 'LOAN',
entityId: id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: fullLoan.employee.id,
type: 'LOAN_REQUEST_ESCALATED',
title: 'تمت إحالة طلب القرض للاعتماد النهائي',
message: `تمت الموافقة المبدئية على طلب القرض رقم ${fullLoan.loanNumber} وإحالته للاعتماد النهائي.`,
entityType: 'LOAN',
entityId: id,
excludeUserIds: [userId],
});
return updatedLoan;
}
}
@@ -954,14 +1070,26 @@ private isSystemAdminUser(user: any) {
),
]);
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LOAN',
entityId: id,
action: loan.status === 'PENDING_ADMIN' ? 'ADMIN_APPROVE' : 'APPROVE',
userId,
});
return this.findLoanById(id);
const approvedLoan = await this.findLoanById(id);
await notificationsService.notifyEmployeeUser({
employeeId: approvedLoan.employee.id,
type: 'LOAN_REQUEST_APPROVED',
title: 'تمت الموافقة على طلب القرض',
message: `تمت الموافقة على طلب القرض الخاص بك برقم ${approvedLoan.loanNumber}.`,
entityType: 'LOAN',
entityId: approvedLoan.id,
excludeUserIds: [userId],
});
return approvedLoan;
}
async rejectLoan(id: string, rejectedReason: string, userId: string) {
@@ -981,7 +1109,7 @@ private isSystemAdminUser(user: any) {
include: { employee: true },
});
await AuditLogger.log({
await AuditLogger.log({
entityType: 'LOAN',
entityId: id,
action: 'REJECT',
@@ -989,6 +1117,18 @@ private isSystemAdminUser(user: any) {
reason: rejectedReason,
});
await notificationsService.notifyEmployeeUser({
employeeId: loan.employeeId,
type: 'LOAN_REQUEST_REJECTED',
title: 'تم رفض طلب القرض',
message: rejectedReason?.trim()
? `تم رفض طلب القرض الخاص بك. السبب: ${rejectedReason.trim()}`
: 'تم رفض طلب القرض الخاص بك.',
entityType: 'LOAN',
entityId: loan.id,
excludeUserIds: [userId],
});
return loan;
}
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
@@ -1063,7 +1203,30 @@ private isSystemAdminUser(user: any) {
},
include: { employee: true },
});
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId });
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId });
const employeeFullName = `${req.employee.firstName} ${req.employee.lastName}`;
await notificationsService.notifyApprovalRecipients({
resource: 'purchase_requests',
type: 'PURCHASE_REQUEST_SUBMITTED',
title: 'طلب شراء جديد بانتظار الموافقة',
message: `قام الموظف ${employeeFullName} بإرسال طلب شراء جديد برقم ${req.requestNumber}.`,
entityType: 'PURCHASE_REQUEST',
entityId: req.id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: req.employeeId,
type: 'PURCHASE_REQUEST_CREATED',
title: 'تم إرسال طلب الشراء',
message: `تم إرسال طلب الشراء الخاص بك برقم ${req.requestNumber} وهو الآن بانتظار المراجعة.`,
entityType: 'PURCHASE_REQUEST',
entityId: req.id,
excludeUserIds: [],
});
return req;
}
@@ -1073,8 +1236,19 @@ private isSystemAdminUser(user: any) {
data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null },
include: { employee: true },
});
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId });
return req;
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId });
await notificationsService.notifyEmployeeUser({
employeeId: req.employeeId,
type: 'PURCHASE_REQUEST_APPROVED',
title: 'تمت الموافقة على طلب الشراء',
message: `تمت الموافقة على طلب الشراء الخاص بك برقم ${req.requestNumber}.`,
entityType: 'PURCHASE_REQUEST',
entityId: req.id,
excludeUserIds: [userId],
});
return req;;
}
async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) {
@@ -1084,6 +1258,19 @@ private isSystemAdminUser(user: any) {
include: { employee: true },
});
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
await notificationsService.notifyEmployeeUser({
employeeId: req.employeeId,
type: 'PURCHASE_REQUEST_REJECTED',
title: 'تم رفض طلب الشراء',
message: rejectedReason?.trim()
? `تم رفض طلب الشراء الخاص بك برقم ${req.requestNumber}. السبب: ${rejectedReason.trim()}`
: `تم رفض طلب الشراء الخاص بك برقم ${req.requestNumber}.`,
entityType: 'PURCHASE_REQUEST',
entityId: req.id,
excludeUserIds: [userId],
});
return req;
}

View File

@@ -1,6 +1,7 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { hrService } from './hr.service';
import { notificationsService } from '../notifications/notifications.service';
class PortalService {
private requireEmployeeId(employeeId: string | undefined): string {
@@ -212,8 +213,32 @@ class PortalService {
});
}
const employeeFullName = `${attendance.employee.firstName} ${attendance.employee.lastName}`;
await notificationsService.notifyApprovalRecipients({
resource: 'overtime_requests',
fallbackEmployeeId: attendance.employeeId,
fallbackToManager: true,
type: 'OVERTIME_REQUEST_SUBMITTED',
title: 'طلب ساعات إضافية جديد بانتظار الموافقة',
message: `قام الموظف ${employeeFullName} بإرسال طلب ساعات إضافية جديد.`,
entityType: 'OVERTIME_REQUEST',
entityId: attendance.id,
excludeUserIds: [userId],
});
await notificationsService.notifyEmployeeUser({
employeeId: attendance.employeeId,
type: 'OVERTIME_REQUEST_CREATED',
title: 'تم إرسال طلب الساعات الإضافية',
message: 'تم إرسال طلب الساعات الإضافية الخاص بك بنجاح وهو الآن بانتظار المراجعة.',
entityType: 'OVERTIME_REQUEST',
entityId: attendance.id,
excludeUserIds: [],
});
return this.formatOvertimeRequest(attendance);
}
}
async getManagedOvertimeRequests(employeeId: string | undefined) {
this.requireEmployeeId(employeeId);
@@ -302,6 +327,16 @@ class PortalService {
},
});
await notificationsService.notifyEmployeeUser({
employeeId: updated.employeeId,
type: 'OVERTIME_REQUEST_APPROVED',
title: 'تمت الموافقة على طلب الساعات الإضافية',
message: 'تمت الموافقة على طلب الساعات الإضافية الخاص بك.',
entityType: 'OVERTIME_REQUEST',
entityId: updated.id,
excludeUserIds: [userId],
});
return this.formatOvertimeRequest(updated);
}
@@ -371,6 +406,18 @@ class PortalService {
},
});
await notificationsService.notifyEmployeeUser({
employeeId: updated.employeeId,
type: 'OVERTIME_REQUEST_REJECTED',
title: 'تم رفض طلب الساعات الإضافية',
message: rejectedReason?.trim()
? `تم رفض طلب الساعات الإضافية الخاص بك. السبب: ${rejectedReason.trim()}`
: 'تم رفض طلب الساعات الإضافية الخاص بك.',
entityType: 'OVERTIME_REQUEST',
entityId: updated.id,
excludeUserIds: [userId],
});
return this.formatOvertimeRequest(updated);
}

View File

@@ -0,0 +1,63 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../../shared/middleware/auth';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
import { notificationsService } from './notifications.service';
class NotificationsController {
async listMyNotifications(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = Number(req.query.page || 1);
const pageSize = Number(req.query.pageSize || 20);
const result = await notificationsService.listMyNotifications(
req.user!.id,
page,
pageSize
);
res.json(
ResponseFormatter.success(result)
);
} catch (error) {
next(error);
}
}
async getUnreadCount(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await notificationsService.getUnreadCount(req.user!.id);
res.json(ResponseFormatter.success(result));
} catch (error) {
next(error);
}
}
async markAsRead(req: AuthRequest, res: Response, next: NextFunction) {
try {
const notification = await notificationsService.markAsRead(
req.params.id,
req.user!.id
);
res.json(
ResponseFormatter.success(notification, 'تم تعليم الإشعار كمقروء - Notification marked as read')
);
} catch (error) {
next(error);
}
}
async markAllAsRead(req: AuthRequest, res: Response, next: NextFunction) {
try {
const result = await notificationsService.markAllAsRead(req.user!.id);
res.json(
ResponseFormatter.success(result, 'تم تعليم كل الإشعارات كمقروءة - All notifications marked as read')
);
} catch (error) {
next(error);
}
}
}
export const notificationsController = new NotificationsController();

View File

@@ -0,0 +1,14 @@
import { Router } from 'express';
import { authenticate } from '../../shared/middleware/auth';
import { notificationsController } from './notifications.controller';
const router = Router();
router.use(authenticate);
router.get('/my', notificationsController.listMyNotifications);
router.get('/unread-count', notificationsController.getUnreadCount);
router.patch('/:id/read', notificationsController.markAsRead);
router.patch('/read-all', notificationsController.markAllAsRead);
export default router;

View File

@@ -0,0 +1,404 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
type CreateNotificationInput = {
userId: string;
type: string;
title: string;
message: string;
entityType?: string | null;
entityId?: string | null;
};
type NotifyManyInput = {
userIds: string[];
type: string;
title: string;
message: string;
entityType?: string | null;
entityId?: string | null;
excludeUserIds?: string[];
};
class NotificationsService {
async listMyNotifications(userId: string, page = 1, pageSize = 20) {
const skip = (page - 1) * pageSize;
const [total, notifications] = await Promise.all([
prisma.notification.count({
where: { userId },
}),
prisma.notification.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
]);
return {
notifications,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
async getUnreadCount(userId: string) {
const count = await prisma.notification.count({
where: {
userId,
isRead: false,
},
});
return { count };
}
async markAsRead(notificationId: string, userId: string) {
const notification = await prisma.notification.findFirst({
where: {
id: notificationId,
userId,
},
});
if (!notification) {
throw new AppError(404, 'الإشعار غير موجود - Notification not found');
}
return prisma.notification.update({
where: { id: notificationId },
data: {
isRead: true,
readAt: new Date(),
},
});
}
async markAllAsRead(userId: string) {
await prisma.notification.updateMany({
where: {
userId,
isRead: false,
},
data: {
isRead: true,
readAt: new Date(),
},
});
return { success: true };
}
async create(input: CreateNotificationInput) {
return prisma.notification.create({
data: {
userId: input.userId,
type: input.type,
title: input.title,
message: input.message,
entityType: input.entityType || null,
entityId: input.entityId || null,
},
});
}
async notifyMany(input: NotifyManyInput) {
const excluded = new Set(input.excludeUserIds || []);
const uniqueUserIds = Array.from(
new Set(input.userIds.filter((id) => !!id && !excluded.has(id)))
);
if (uniqueUserIds.length === 0) {
return [];
}
const created = [];
for (const userId of uniqueUserIds) {
const notification = await prisma.notification.create({
data: {
userId,
type: input.type,
title: input.title,
message: input.message,
entityType: input.entityType || null,
entityId: input.entityId || null,
},
});
created.push(notification);
}
return created;
}
async findUserByEmployeeId(employeeId: string) {
return prisma.user.findFirst({
where: {
employeeId,
isActive: true,
},
select: {
id: true,
employeeId: true,
email: true,
username: true,
},
});
}
async findManagerUserByEmployeeId(employeeId: string) {
const employee = await prisma.employee.findUnique({
where: { id: employeeId },
select: {
id: true,
reportingToId: true,
},
});
if (!employee?.reportingToId) {
return null;
}
return prisma.user.findFirst({
where: {
employeeId: employee.reportingToId,
isActive: true,
},
select: {
id: true,
employeeId: true,
email: true,
username: true,
},
});
}
async notifyEmployeeUser(params: {
employeeId: string;
type: string;
title: string;
message: string;
entityType?: string;
entityId?: string;
excludeUserIds?: string[];
}) {
const user = await this.findUserByEmployeeId(params.employeeId);
if (!user) return null;
return this.notifyMany({
userIds: [user.id],
type: params.type,
title: params.title,
message: params.message,
entityType: params.entityType,
entityId: params.entityId,
excludeUserIds: params.excludeUserIds,
});
}
async notifyManagerForEmployee(params: {
employeeId: string;
type: string;
title: string;
message: string;
entityType?: string;
entityId?: string;
excludeUserIds?: string[];
}) {
const managerUser = await this.findManagerUserByEmployeeId(params.employeeId);
if (!managerUser) return null;
return this.notifyMany({
userIds: [managerUser.id],
type: params.type,
title: params.title,
message: params.message,
entityType: params.entityType,
entityId: params.entityId,
excludeUserIds: params.excludeUserIds,
});
}
async resolveApprovalRecipients(params: {
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests';
fallbackEmployeeId?: string;
fallbackToManager?: boolean;
excludeUserIds?: string[];
}) {
const notifyUsers = await this.findUsersWithPermission({
module: params.resource,
resource: 'all',
action: 'notify',
excludeUserIds: params.excludeUserIds,
});
if (notifyUsers.length > 0) {
return notifyUsers;
}
const approveUsers = await this.findUsersWithPermission({
module: params.resource,
resource: 'all',
action: 'approve',
excludeUserIds: params.excludeUserIds,
});
if (approveUsers.length > 0) {
return approveUsers;
}
if (params.fallbackToManager && params.fallbackEmployeeId) {
const managerUser = await this.findManagerUserByEmployeeId(params.fallbackEmployeeId);
if (managerUser) {
return [managerUser];
}
}
return [];
}
async notifyApprovalRecipients(params: {
resource: 'leave_requests' | 'overtime_requests' | 'loan_requests' | 'purchase_requests';
fallbackEmployeeId?: string;
fallbackToManager?: boolean;
type: string;
title: string;
message: string;
entityType?: string;
entityId?: string;
excludeUserIds?: string[];
}) {
const recipients = await this.resolveApprovalRecipients({
resource: params.resource,
fallbackEmployeeId: params.fallbackEmployeeId,
fallbackToManager: params.fallbackToManager,
excludeUserIds: params.excludeUserIds,
});
return this.notifyMany({
userIds: recipients.map((u) => u.id),
type: params.type,
title: params.title,
message: params.message,
entityType: params.entityType,
entityId: params.entityId,
excludeUserIds: params.excludeUserIds,
});
}
async findUsersWithPermission(params: {
module: string;
resource?: string;
action: string;
departmentId?: string;
excludeUserIds?: string[];
}) {
const users = await prisma.user.findMany({
where: {
isActive: true,
employee: {
status: 'ACTIVE',
},
},
include: {
employee: {
include: {
position: {
include: {
permissions: true,
},
},
department: true,
},
},
userRoles: {
where: {
role: {
isActive: true,
},
},
include: {
role: {
include: {
permissions: true,
},
},
},
},
},
});
const resource = params.resource || '*';
const excluded = new Set(params.excludeUserIds || []);
const matched = users.filter((user: any) => {
if (excluded.has(user.id)) return false;
if (!user.employee) return false;
if (params.departmentId && user.employee.departmentId !== params.departmentId) {
return false;
}
const positionPerms = user.employee?.position?.permissions || [];
const rolePerms = (user.userRoles || []).flatMap((ur: any) => ur.role?.permissions || []);
const allPerms = [...positionPerms, ...rolePerms];
return allPerms.some((perm: any) => {
const moduleMatch = perm.module === params.module;
const resourceMatch =
perm.resource === resource ||
perm.resource === '*' ||
perm.resource === 'all';
const actions = Array.isArray(perm.actions) ? perm.actions : [];
const actionMatch =
actions.includes(params.action) ||
actions.includes('*') ||
actions.includes('all');
return moduleMatch && resourceMatch && actionMatch;
});
});
return matched.map((u: any) => ({
id: u.id,
employeeId: u.employeeId,
username: u.username,
email: u.email,
}));
}
async notifyUsersWithPermission(params: {
module: string;
resource?: string;
action: string;
departmentId?: string;
type: string;
title: string;
message: string;
entityType?: string;
entityId?: string;
excludeUserIds?: string[];
}) {
const users = await this.findUsersWithPermission({
module: params.module,
resource: params.resource,
action: params.action,
departmentId: params.departmentId,
excludeUserIds: params.excludeUserIds,
});
return this.notifyMany({
userIds: users.map((u) => u.id),
type: params.type,
title: params.title,
message: params.message,
entityType: params.entityType,
entityId: params.entityId,
excludeUserIds: params.excludeUserIds,
});
}
}
export const notificationsService = new NotificationsService();

View File

@@ -1,6 +1,7 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger';
import { notificationsService } from '../notifications/notifications.service';
import { Prisma } from '@prisma/client';
import path from 'path';
import fs from 'fs'
@@ -517,20 +518,20 @@ class TendersService {
},
});
const assignedUser = directive.assignedToEmployee?.user;
if (assignedUser?.id) {
const typeLabel = this.getDirectiveTypeLabel(data.type);
await prisma.notification.create({
data: {
userId: assignedUser.id,
type: 'TENDER_DIRECTIVE_ASSIGNED',
title: `مهمة مناقصة - Tender task: ${tender.tenderNumber}`,
message: `${tender.title} (${tender.tenderNumber}): ${typeLabel}. ${data.notes || ''}`,
entityType: 'TENDER_DIRECTIVE',
entityId: directive.id,
},
});
}
const assignedUser = directive.assignedToEmployee?.user;
if (assignedUser?.id) {
const typeLabel = this.getDirectiveTypeLabel(data.type);
await notificationsService.notifyMany({
userIds: [assignedUser.id],
type: 'TENDER_DIRECTIVE_ASSIGNED',
title: 'تم إسناد توجيه مناقصة جديد',
message: `تم إسناد توجيه "${typeLabel}" لك في المناقصة ${tender.tenderNumber}${data.notes?.trim() ? ` - ${data.notes.trim()}` : ''}`,
entityType: 'TENDER',
entityId: tender.id,
excludeUserIds: [userId],
});
}
await AuditLogger.log({
entityType: 'TENDER_DIRECTIVE',

View File

@@ -9,6 +9,7 @@ import inventoryRoutes from '../modules/inventory/inventory.routes';
import projectsRoutes from '../modules/projects/projects.routes';
import marketingRoutes from '../modules/marketing/marketing.routes';
import tendersRoutes from '../modules/tenders/tenders.routes';
import notificationsRoutes from '../modules/notifications/notifications.routes';
const router = Router();
@@ -23,6 +24,7 @@ router.use('/inventory', inventoryRoutes);
router.use('/projects', projectsRoutes);
router.use('/marketing', marketingRoutes);
router.use('/tenders', tendersRoutes);
router.use('/notifications', notificationsRoutes);
// API info
router.get('/', (req, res) => {