Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 549da39473 | |||
| 096297d1d1 | |||
| 9fa55cc6b5 | |||
| d1f85d78f4 |
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../dmenu-admin"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
@@ -710,6 +710,8 @@ export const enum OrderMessage {
|
|||||||
REFUND_AMOUNT_EXCEEDS_PAID = 'مبلغ بازگشت وجه بیشتر از مبلغ پرداختشده است',
|
REFUND_AMOUNT_EXCEEDS_PAID = 'مبلغ بازگشت وجه بیشتر از مبلغ پرداختشده است',
|
||||||
REFUND_AMOUNT_EXCEEDS_OVERPAYMENT = 'مبلغ بازگشت وجه بیشتر از مانده منفی سفارش است',
|
REFUND_AMOUNT_EXCEEDS_OVERPAYMENT = 'مبلغ بازگشت وجه بیشتر از مانده منفی سفارش است',
|
||||||
NO_PAID_PAYMENTS_TO_REFUND = 'پرداختی برای بازگشت وجه یافت نشد',
|
NO_PAID_PAYMENTS_TO_REFUND = 'پرداختی برای بازگشت وجه یافت نشد',
|
||||||
|
ADD_PAYMENT_NOT_ALLOWED = 'امکان افزودن پرداخت برای این سفارش وجود ندارد',
|
||||||
|
ADD_PAYMENT_NO_BALANCE = 'ماندهای برای پرداخت وجود ندارد',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum CartMessage {
|
export const enum CartMessage {
|
||||||
|
|||||||
@@ -177,6 +177,19 @@ export class NotificationsController {
|
|||||||
return { smsCount };
|
return { smsCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Post('admin/notifications/test-notis')
|
||||||
|
@ApiOperation({ summary: 'Send a test in-app notification (socket) to the current admin' })
|
||||||
|
async createTestNotis(@RestId() restaurantId: string, @AdminId() adminId: string) {
|
||||||
|
const notification = await this.notificationService.sendTestInAppNotification(adminId, restaurantId);
|
||||||
|
return {
|
||||||
|
message: 'Test in-app notification queued',
|
||||||
|
notification,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// super admin endpoints
|
// super admin endpoints
|
||||||
|
|
||||||
@UseGuards(SuperAdminAuthGuard)
|
@UseGuards(SuperAdminAuthGuard)
|
||||||
|
|||||||
@@ -37,10 +37,19 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handleConnection(client: Socket) {
|
async handleConnection(client: Socket) {
|
||||||
|
this.logger.log(
|
||||||
|
`[WS] connection attempt socketId=${client.id} transport=${client.conn.transport.name} ` +
|
||||||
|
`hasAuthToken=${Boolean(client.handshake.auth?.token || client.handshake.auth?.authorization)} ` +
|
||||||
|
`hasQueryToken=${Boolean(client.handshake.query?.token || client.handshake.query?.authorization)} ` +
|
||||||
|
`hasHeaderAuth=${Boolean(client.handshake.headers.authorization)}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.authenticateConnection(client);
|
await this.authenticateConnection(client);
|
||||||
const authenticatedClient = client as AuthenticatedSocket;
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
this.logger.log(`Admin connected: ${authenticatedClient.adminId}`);
|
this.logger.log(
|
||||||
|
`[WS] connected socketId=${client.id} adminId=${authenticatedClient.adminId} restId=${authenticatedClient.restId}`,
|
||||||
|
);
|
||||||
this.handleJoinRoom(authenticatedClient);
|
this.handleJoinRoom(authenticatedClient);
|
||||||
|
|
||||||
// Start ping interval for this client
|
// Start ping interval for this client
|
||||||
@@ -51,7 +60,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
// this.pingIntervals.set(client.id, intervalId);
|
// this.pingIntervals.set(client.id, intervalId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Connection authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
`[WS] connection auth failed socketId=${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
);
|
);
|
||||||
void client.emit('error', { message: 'Authentication failed' });
|
void client.emit('error', { message: 'Authentication failed' });
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
@@ -59,8 +68,11 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleDisconnect(client: Socket) {
|
handleDisconnect(client: Socket) {
|
||||||
this.logger.log(`Client disconnected: ${client.id}`);
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
this.handleLeaveRoom(client);
|
this.logger.log(
|
||||||
|
`[WS] disconnect socketId=${client.id} adminId=${authenticatedClient.adminId ?? 'n/a'} restId=${authenticatedClient.restId ?? 'n/a'}`,
|
||||||
|
);
|
||||||
|
this.handleLeaveRoom(authenticatedClient);
|
||||||
|
|
||||||
// Clear ping interval for this client
|
// Clear ping interval for this client
|
||||||
// const intervalId = this.pingIntervals.get(client.id);
|
// const intervalId = this.pingIntervals.get(client.id);
|
||||||
@@ -72,13 +84,41 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
|
|
||||||
sendInAppNotification(repipient: { adminId: string; restaurantId: string }, payload: IInAppNotificationPayload) {
|
sendInAppNotification(repipient: { adminId: string; restaurantId: string }, payload: IInAppNotificationPayload) {
|
||||||
const room = this.getRoom(repipient.adminId, repipient.restaurantId);
|
const room = this.getRoom(repipient.adminId, repipient.restaurantId);
|
||||||
|
const adapter = this.server.sockets?.adapter ?? this.server.adapter;
|
||||||
|
const socketsMap = this.server.sockets?.sockets ?? this.server.sockets;
|
||||||
|
const socketsInRoom = adapter?.rooms?.get(room);
|
||||||
|
const roomSize = socketsInRoom?.size ?? 0;
|
||||||
|
const connectedSocketIds =
|
||||||
|
socketsMap instanceof Map ? [...socketsMap.keys()] : [...(Object.keys(socketsMap ?? {}) as string[])];
|
||||||
|
const allRooms = adapter?.rooms ? [...adapter.rooms.keys()] : [];
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[WS] emit start room=${room} adminId=${repipient.adminId} restaurantId=${repipient.restaurantId} ` +
|
||||||
|
`notificationId=${payload.notificationId} subject=${payload.subject} roomSize=${roomSize} ` +
|
||||||
|
`namespaceConnected=${connectedSocketIds.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (roomSize === 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[WS] emit to EMPTY room=${room} — no client joined this room. ` +
|
||||||
|
`connectedSockets=[${connectedSocketIds.join(', ') || 'none'}] ` +
|
||||||
|
`allRooms=[${allRooms.join(', ') || 'none'}]`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.log(`[WS] room members socketIds=[${[...(socketsInRoom ?? [])].join(', ')}]`);
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(`Sending in app notification to admin: ${repipient.adminId}`);
|
|
||||||
this.server.to(room).emit('notifications', payload, async () => {
|
this.server.to(room).emit('notifications', payload, async () => {
|
||||||
|
this.logger.log(
|
||||||
|
`[WS] ACK received room=${room} notificationId=${payload.notificationId} (client acknowledged)`,
|
||||||
|
);
|
||||||
// 3. ACK (delivered)
|
// 3. ACK (delivered)
|
||||||
// await this.notificationService.markAsDelivered(payload.notificationId);
|
// await this.notificationService.markAsDelivered(payload.notificationId);
|
||||||
});
|
});
|
||||||
this.logger.log(`In app notification sent to admin: ${repipient.adminId}`);
|
|
||||||
|
this.logger.log(
|
||||||
|
`[WS] emit queued event=notifications room=${room} roomSize=${roomSize} notificationId=${payload.notificationId}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRoom(adminId: string, restaurantId: string) {
|
private getRoom(adminId: string, restaurantId: string) {
|
||||||
@@ -86,16 +126,30 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleLeaveRoom(client: AuthenticatedSocket) {
|
handleLeaveRoom(client: AuthenticatedSocket) {
|
||||||
const room = this.getRoom(client.adminId!, client.restId!);
|
if (!client.adminId || !client.restId) {
|
||||||
|
this.logger.warn(`[WS] leave skipped socketId=${client.id} — missing adminId/restId`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = this.getRoom(client.adminId, client.restId);
|
||||||
void client.leave(room);
|
void client.leave(room);
|
||||||
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) left room: ${room}`);
|
this.logger.log(`[WS] left room=${room} adminId=${client.adminId} socketId=${client.id}`);
|
||||||
void client.emit('left', { room, message: 'Successfully Admin left room' });
|
void client.emit('left', { room, message: 'Successfully Admin left room' });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleJoinRoom(client: AuthenticatedSocket) {
|
handleJoinRoom(client: AuthenticatedSocket) {
|
||||||
const room = this.getRoom(client.adminId!, client.restId!);
|
if (!client.adminId || !client.restId) {
|
||||||
|
this.logger.warn(`[WS] join skipped socketId=${client.id} — missing adminId/restId`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = this.getRoom(client.adminId, client.restId);
|
||||||
void client.join(room);
|
void client.join(room);
|
||||||
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) joined room: ${room}`);
|
const adapter = this.server.sockets?.adapter ?? this.server.adapter;
|
||||||
|
const roomSize = adapter?.rooms?.get(room)?.size ?? 0;
|
||||||
|
this.logger.log(
|
||||||
|
`[WS] joined room=${room} adminId=${client.adminId} restId=${client.restId} socketId=${client.id} roomSize=${roomSize}`,
|
||||||
|
);
|
||||||
void client.emit('joined', { room, message: 'Successfully Admin joined room' });
|
void client.emit('joined', { room, message: 'Successfully Admin joined room' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Job } from 'bullmq';
|
import { Job } from 'bullmq';
|
||||||
import { CreateRequestContext } from '@mikro-orm/core';
|
import { CreateRequestContext } from '@mikro-orm/core';
|
||||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
import { Notification } from '../entities/notification.entity';
|
|
||||||
import { InAppNotificationQueueJob } from '../interfaces/jobs-queue.interface';
|
import { InAppNotificationQueueJob } from '../interfaces/jobs-queue.interface';
|
||||||
import { NotificationQueueNameEnum } from '../constants/queue';
|
import { NotificationQueueNameEnum } from '../constants/queue';
|
||||||
import { NotificationsGateway } from '../notifications.gateway';
|
import { NotificationsGateway } from '../notifications.gateway';
|
||||||
@@ -14,7 +12,6 @@ export class InAppProcessor extends WorkerHost {
|
|||||||
private readonly logger = new Logger(InAppProcessor.name);
|
private readonly logger = new Logger(InAppProcessor.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Notification)
|
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
private readonly notificationsGateway: NotificationsGateway,
|
private readonly notificationsGateway: NotificationsGateway,
|
||||||
) {
|
) {
|
||||||
@@ -25,10 +22,18 @@ export class InAppProcessor extends WorkerHost {
|
|||||||
async process(job: Job<InAppNotificationQueueJob>): Promise<void> {
|
async process(job: Job<InAppNotificationQueueJob>): Promise<void> {
|
||||||
const { recipient, subject, body, notificationId } = job.data;
|
const { recipient, subject, body, notificationId } = job.data;
|
||||||
|
|
||||||
this.logger.log(`Processing InApp notification - Recipient: ${JSON.stringify(recipient)}, subject: ${subject}`);
|
this.logger.log(
|
||||||
|
`[InAppProcessor] processing jobId=${job.id} attempt=${job.attemptsMade + 1} ` +
|
||||||
|
`recipient=${JSON.stringify(recipient)} subject=${subject} notificationId=${notificationId}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!recipient.adminId) {
|
if (!recipient?.adminId) {
|
||||||
this.logger.warn(`Skipping in-app notification job ${job.id}: missing adminId`);
|
this.logger.warn(`[InAppProcessor] skipping job ${job.id}: missing adminId`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recipient.restaurantId) {
|
||||||
|
this.logger.warn(`[InAppProcessor] skipping job ${job.id}: missing restaurantId`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,20 +43,30 @@ export class InAppProcessor extends WorkerHost {
|
|||||||
{ subject, body, notificationId },
|
{ subject, body, notificationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`InApp notification sent successfully to ${recipient.adminId}`);
|
this.logger.log(
|
||||||
|
`[InAppProcessor] gateway emit done for admin=${recipient.adminId} restaurant=${recipient.restaurantId}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error processing InApp notification job ${job.id}:`, error);
|
this.logger.error(`[InAppProcessor] error processing job ${job.id}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnWorkerEvent('active')
|
||||||
|
onActive(job: Job) {
|
||||||
|
this.logger.log(`[InAppProcessor] job ${job.id} became active`);
|
||||||
|
}
|
||||||
|
|
||||||
@OnWorkerEvent('completed')
|
@OnWorkerEvent('completed')
|
||||||
onCompleted(job: Job) {
|
onCompleted(job: Job) {
|
||||||
this.logger.log(`Notification job ${job.id} completed`);
|
this.logger.log(`[InAppProcessor] job ${job.id} completed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnWorkerEvent('failed')
|
@OnWorkerEvent('failed')
|
||||||
onFailed(job: Job, error: Error) {
|
onFailed(job: Job, error: Error) {
|
||||||
this.logger.error(`Notification job ${job.id} failed:`, error);
|
this.logger.error(
|
||||||
|
`[InAppProcessor] job ${job.id} failed after ${job.attemptsMade} attempt(s): ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,12 @@ export class NotificationQueueService {
|
|||||||
}
|
}
|
||||||
async addBulkInAppNotifications(jobs: InAppNotificationQueueJob[]): Promise<void> {
|
async addBulkInAppNotifications(jobs: InAppNotificationQueueJob[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
this.logger.log(
|
||||||
|
`[addBulkInAppNotifications] queueing ${jobs.length} job(s): ${jobs
|
||||||
|
.map(j => `admin=${j.recipient.adminId} rest=${j.recipient.restaurantId} notif=${j.notificationId}`)
|
||||||
|
.join(' | ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
const queueJobs = jobs.map(job => ({
|
const queueJobs = jobs.map(job => ({
|
||||||
name: 'in-app-notification',
|
name: 'in-app-notification',
|
||||||
data: job,
|
data: job,
|
||||||
@@ -132,10 +138,12 @@ export class NotificationQueueService {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await this.inAppQueue.addBulk(queueJobs);
|
const added = await this.inAppQueue.addBulk(queueJobs);
|
||||||
this.logger.log(`Added ${jobs.length} InApp notification jobs to queue`);
|
this.logger.log(
|
||||||
|
`[addBulkInAppNotifications] added ${added.length} job(s) to queue "${NotificationQueueNameEnum.IN_APP}": ids=${added.map(j => j.id).join(', ')}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to add bulk notifications to queue:`, error);
|
this.logger.error(`[addBulkInAppNotifications] failed to add jobs to queue:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export class NotificationService {
|
|||||||
async sendNotification(params: NotifRequest): Promise<Notification[]> {
|
async sendNotification(params: NotifRequest): Promise<Notification[]> {
|
||||||
const { recipients, message, metadata, restaurantId } = params;
|
const { recipients, message, metadata, restaurantId } = params;
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[sendNotification] start restaurant=${restaurantId} title=${message.title} recipients=${recipients.length} ` +
|
||||||
|
`admins=${recipients.filter(r => 'adminId' in r).length} users=${recipients.filter(r => 'userId' in r).length}`,
|
||||||
|
);
|
||||||
|
|
||||||
// create Database notifications
|
// create Database notifications
|
||||||
const notifications = await this.createAdminBulkNotifications(
|
const notifications = await this.createAdminBulkNotifications(
|
||||||
recipients.map(recipient => ({
|
recipients.map(recipient => ({
|
||||||
@@ -35,18 +40,35 @@ export class NotificationService {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.logger.log(`[sendNotification] created ${notifications.length} DB notification(s)`);
|
||||||
|
|
||||||
// get admin prefrences
|
// get admin prefrences
|
||||||
const preference = await this.preferenceService.findByRestaurantAndType(restaurantId, message.title);
|
const preference = await this.preferenceService.findByRestaurantAndType(restaurantId, message.title);
|
||||||
|
|
||||||
if (preference?.channels?.length === 0) {
|
this.logger.log(
|
||||||
|
`[sendNotification] preference=${preference ? `id=${preference.id} channels=${JSON.stringify(preference.channels)}` : 'NOT FOUND'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!preference) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[sendNotification] skipping channels — no preference for restaurant ${restaurantId}, title ${message.title}`,
|
||||||
|
);
|
||||||
|
return notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preference.channels?.length === 0) {
|
||||||
this.logger.warn(`Notification type is NONE for restaurant ${restaurantId}, title ${message.title}`);
|
this.logger.warn(`Notification type is NONE for restaurant ${restaurantId}, title ${message.title}`);
|
||||||
return notifications;
|
return notifications;
|
||||||
}
|
}
|
||||||
|
|
||||||
// send in app notification (admins only — user records must not emit to admin socket rooms)
|
// send in app notification (admins only — user records must not emit to admin socket rooms)
|
||||||
if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) {
|
if (preference.channels?.includes(NotifChannelEnum.IN_APP)) {
|
||||||
|
this.logger.log(`[sendNotification] IN_APP channel enabled, building jobs`);
|
||||||
const inAppJobs = recipients.flatMap((recipient, index) => {
|
const inAppJobs = recipients.flatMap((recipient, index) => {
|
||||||
if (!('adminId' in recipient) || !recipient.adminId) {
|
if (!('adminId' in recipient) || !recipient.adminId) {
|
||||||
|
this.logger.debug(
|
||||||
|
`[sendNotification] skipping recipient index=${index} (not admin): ${JSON.stringify(recipient)}`,
|
||||||
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,16 +83,23 @@ export class NotificationService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (inAppJobs.length > 0) {
|
if (inAppJobs.length > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`[sendNotification] queueing ${inAppJobs.length} in-app job(s): ${inAppJobs.map(j => j.recipient.adminId).join(', ')}`,
|
||||||
|
);
|
||||||
await this.queueService.addBulkInAppNotifications(inAppJobs);
|
await this.queueService.addBulkInAppNotifications(inAppJobs);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`No admin recipients for in-app notification: restaurant ${restaurantId}, title ${message.title}`,
|
`No admin recipients for in-app notification: restaurant ${restaurantId}, title ${message.title}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
`[sendNotification] IN_APP channel NOT enabled for restaurant ${restaurantId}, title ${message.title}, channels=${JSON.stringify(preference.channels)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// add sms notifications to queue
|
// add sms notifications to queue
|
||||||
if (preference?.channels?.includes(NotifChannelEnum.SMS)) {
|
if (preference.channels?.includes(NotifChannelEnum.SMS)) {
|
||||||
await this.queueService.addBulkSmsNotifications(
|
await this.queueService.addBulkSmsNotifications(
|
||||||
recipients.map(recipient => ({
|
recipients.map(recipient => ({
|
||||||
recipient,
|
recipient,
|
||||||
@@ -293,4 +322,40 @@ export class NotificationService {
|
|||||||
async getSmsCountByRestaurantId(restaurantId: string): Promise<number> {
|
async getSmsCountByRestaurantId(restaurantId: string): Promise<number> {
|
||||||
return this.smsLogRepository.getSmsCountByRestaurantId(restaurantId);
|
return this.smsLogRepository.getSmsCountByRestaurantId(restaurantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendTestInAppNotification(adminId: string, restaurantId: string): Promise<Notification> {
|
||||||
|
const title = NotifTitleEnum.ORDER_CREATED;
|
||||||
|
const content = 'Test in-app notification';
|
||||||
|
|
||||||
|
this.logger.log(`[sendTestInAppNotification] start adminId=${adminId} restaurantId=${restaurantId}`);
|
||||||
|
|
||||||
|
const [notification] = await this.createAdminBulkNotifications([
|
||||||
|
{
|
||||||
|
restaurantId,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
adminId,
|
||||||
|
userId: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[sendTestInAppNotification] created notification id=${notification.id}, queueing in-app job`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.queueService.addBulkInAppNotifications([
|
||||||
|
{
|
||||||
|
recipient: { adminId, restaurantId },
|
||||||
|
subject: title,
|
||||||
|
body: content,
|
||||||
|
notificationId: notification.id,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[sendTestInAppNotification] queued successfully admin=${adminId} restaurant=${restaurantId} notificationId=${notification.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto';
|
|||||||
import { FoodOrderReportDto } from '../dto/food-order-report.dto';
|
import { FoodOrderReportDto } from '../dto/food-order-report.dto';
|
||||||
import { DailyOrderReportDto } from '../dto/daily-order-report.dto';
|
import { DailyOrderReportDto } from '../dto/daily-order-report.dto';
|
||||||
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
|
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
|
||||||
|
import { AdminAddPaymentDto } from '../dto/admin-add-payment.dto';
|
||||||
|
|
||||||
@ApiTags('orders')
|
@ApiTags('orders')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -155,6 +156,20 @@ export class OrdersController {
|
|||||||
return this.ordersService.removeOrderItem(orderId, restId, itemId);
|
return this.ordersService.removeOrderItem(orderId, restId, itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_ORDERS)
|
||||||
|
@Post('admin/orders/:orderId/payments')
|
||||||
|
@ApiOperation({ summary: 'Add a cash payment to an existing order' })
|
||||||
|
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
|
@ApiBody({ type: AdminAddPaymentDto })
|
||||||
|
addPayment(
|
||||||
|
@Param('orderId') orderId: string,
|
||||||
|
@Body() dto: AdminAddPaymentDto,
|
||||||
|
@RestId() restId: string,
|
||||||
|
) {
|
||||||
|
return this.ordersService.addPayment(orderId, restId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@Permissions(Permission.MANAGE_ORDERS)
|
@Permissions(Permission.MANAGE_ORDERS)
|
||||||
@Post('admin/orders/:orderId/refund')
|
@Post('admin/orders/:orderId/refund')
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class AdminAddPaymentDto {
|
||||||
|
@ApiProperty({ description: 'Payment amount', minimum: 1 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Payment description' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto';
|
|||||||
import { AdminAddOrderItemDto } from '../dto/admin-add-order-item.dto';
|
import { AdminAddOrderItemDto } from '../dto/admin-add-order-item.dto';
|
||||||
import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto';
|
import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto';
|
||||||
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
|
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
|
||||||
|
import { AdminAddPaymentDto } from '../dto/admin-add-payment.dto';
|
||||||
import { FoodOrderReportDto, FoodOrderReportSortBy } from '../dto/food-order-report.dto';
|
import { FoodOrderReportDto, FoodOrderReportSortBy } from '../dto/food-order-report.dto';
|
||||||
import { DailyOrderReportDto, DailyOrderReportSortBy } from '../dto/daily-order-report.dto';
|
import { DailyOrderReportDto, DailyOrderReportSortBy } from '../dto/daily-order-report.dto';
|
||||||
import { CashShiftsService } from './cash-shifts.service';
|
import { CashShiftsService } from './cash-shifts.service';
|
||||||
@@ -545,6 +546,52 @@ export class OrdersService {
|
|||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addPayment(orderId: string, restId: string, dto: AdminAddPaymentDto): Promise<Order> {
|
||||||
|
return this.em.transactional(async em => {
|
||||||
|
const order = await em.findOne(
|
||||||
|
Order,
|
||||||
|
{ id: orderId, restaurant: { id: restId } },
|
||||||
|
{ populate: ['payments', 'user', 'paymentMethod', 'deliveryMethod', 'items', 'items.food'] },
|
||||||
|
);
|
||||||
|
if (!order) {
|
||||||
|
throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === OrderStatus.CANCELED) {
|
||||||
|
throw new BadRequestException(OrderMessage.ADD_PAYMENT_NOT_ALLOWED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const balance = Number(order.balance ?? 0);
|
||||||
|
if (balance <= 0) {
|
||||||
|
throw new BadRequestException(OrderMessage.ADD_PAYMENT_NO_BALANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = Number(dto.amount);
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = em.create(Payment, {
|
||||||
|
order,
|
||||||
|
amount,
|
||||||
|
method: PaymentMethodEnum.Cash,
|
||||||
|
gateway: null,
|
||||||
|
status: PaymentStatusEnum.Paid,
|
||||||
|
paidAt: new Date(),
|
||||||
|
description: dto.description?.trim() || null,
|
||||||
|
});
|
||||||
|
em.persist(payment);
|
||||||
|
|
||||||
|
await em.flush();
|
||||||
|
|
||||||
|
return em.findOneOrFail(
|
||||||
|
Order,
|
||||||
|
{ id: orderId },
|
||||||
|
{ populate: ['payments', 'user', 'paymentMethod', 'deliveryMethod', 'items', 'items.food'] },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async refundOrder(orderId: string, restId: string, dto: AdminRefundOrderDto): Promise<Order> {
|
async refundOrder(orderId: string, restId: string, dto: AdminRefundOrderDto): Promise<Order> {
|
||||||
return this.em.transactional(async em => {
|
return this.em.transactional(async em => {
|
||||||
const order = await em.findOne(
|
const order = await em.findOne(
|
||||||
|
|||||||
@@ -33,15 +33,11 @@ export class RolesController {
|
|||||||
@Get('admin/roles/permissions')
|
@Get('admin/roles/permissions')
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Get all permissions that the admin has' })
|
@ApiOperation({ summary: 'Get all permissions ' })
|
||||||
async findAllPermissions(@AdminId() adminId: string, @RestId() restId: string) {
|
async findAllPermissions() {
|
||||||
const adminPermissionNames = await this.permissionService.getAdminFullPermissions(adminId, restId);
|
return this.permissionService.getPermissions();
|
||||||
|
|
||||||
return adminPermissionNames;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Post('admin/roles')
|
@Post('admin/roles')
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@Permissions(Permission.MANAGE_ROLES)
|
@Permissions(Permission.MANAGE_ROLES)
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export class PermissionsService {
|
|||||||
return permissionNames;
|
return permissionNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPermissions() {
|
||||||
|
return this.permissionRepository.findAll()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh admin permissions in Redis (used after login or role changes)
|
* Refresh admin permissions in Redis (used after login or role changes)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -215,6 +215,17 @@ export class UsersController {
|
|||||||
return this.userService.adminUpdateUser(userId, restId, dto);
|
return this.userService.adminUpdateUser(userId, restId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permissions(Permission.MANAGE_USERS)
|
||||||
|
@ApiHeader(API_HEADER_SLUG)
|
||||||
|
@ApiOperation({ summary: 'Get user groups for a customer (admin)' })
|
||||||
|
@ApiParam({ name: 'userId', description: 'User ID' })
|
||||||
|
@Get('admin/users/:userId/groups')
|
||||||
|
async adminGetUserGroups(@Param('userId') userId: string, @RestId() restId: string) {
|
||||||
|
return this.userService.getUserGroups(userId, restId);
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Permissions(Permission.MANAGE_USERS)
|
@Permissions(Permission.MANAGE_USERS)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsBoolean, IsMobilePhone, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsArray, IsBoolean, IsMobilePhone, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class CreateUserDto {
|
export class CreateUserDto {
|
||||||
@ApiProperty({ description: "User's phone number", example: '09121234567' })
|
@ApiProperty({ description: "User's phone number", example: '09121234567' })
|
||||||
@@ -37,4 +37,15 @@ export class CreateUserDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'User group IDs to assign the customer to',
|
||||||
|
type: [String],
|
||||||
|
example: ['01HXABCDEFGHIJKLMNOPQRSTUV'],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsNotEmpty({ each: true })
|
||||||
|
groupIds?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsString, IsOptional, IsBoolean } from 'class-validator';
|
import { IsString, IsOptional, IsBoolean, IsArray, IsNotEmpty } from 'class-validator';
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class UpdateUserDto {
|
export class UpdateUserDto {
|
||||||
@@ -32,4 +32,15 @@ export class UpdateUserDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'User group IDs to assign the customer to (replaces current groups)',
|
||||||
|
type: [String],
|
||||||
|
example: ['01HXABCDEFGHIJKLMNOPQRSTUV'],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsNotEmpty({ each: true })
|
||||||
|
groupIds?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,4 +138,42 @@ export class UserGroupService {
|
|||||||
group.count = Math.max(0, group.count - 1);
|
group.count = Math.max(0, group.count - 1);
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async assignUserToGroups(restId: string, userId: string, groupIds: string[]): Promise<void> {
|
||||||
|
const uniqueGroupIds = [...new Set(groupIds)];
|
||||||
|
for (const groupId of uniqueGroupIds) {
|
||||||
|
await this.addUsers(restId, groupId, { userIds: [userId] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroupsForUser(restId: string, userId: string): Promise<UserGroup[]> {
|
||||||
|
const links = await this.userGroupUserRepository.find(
|
||||||
|
{
|
||||||
|
user: { id: userId },
|
||||||
|
userGroup: { restaurant: { id: restId }, deletedAt: null },
|
||||||
|
},
|
||||||
|
{ populate: ['userGroup'], orderBy: { createdAt: 'asc' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
return links.map(link => link.userGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncUserGroups(restId: string, userId: string, groupIds: string[]): Promise<void> {
|
||||||
|
const targetGroupIds = [...new Set(groupIds)];
|
||||||
|
const currentGroups = await this.getGroupsForUser(restId, userId);
|
||||||
|
const currentGroupIds = new Set(currentGroups.map(group => group.id));
|
||||||
|
const targetGroupIdsSet = new Set(targetGroupIds);
|
||||||
|
|
||||||
|
for (const group of currentGroups) {
|
||||||
|
if (!targetGroupIdsSet.has(group.id)) {
|
||||||
|
await this.removeUser(restId, group.id, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const groupId of targetGroupIds) {
|
||||||
|
if (!currentGroupIds.has(groupId)) {
|
||||||
|
await this.addUsers(restId, groupId, { userIds: [userId] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { UserRestaurant } from '../entities/user-restuarant.entity';
|
|||||||
import { ImportUsersResult } from '../interfaces/import-users.interface';
|
import { ImportUsersResult } from '../interfaces/import-users.interface';
|
||||||
import { assertExcelMimeType, parseUsersExcel } from '../utils/import-users-excel.util';
|
import { assertExcelMimeType, parseUsersExcel } from '../utils/import-users-excel.util';
|
||||||
import { AuthMessage, UserImportMessage } from 'src/common/enums/message.enum';
|
import { AuthMessage, UserImportMessage } from 'src/common/enums/message.enum';
|
||||||
|
import { UserGroupService } from './user-group.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
@@ -35,6 +36,7 @@ export class UserService {
|
|||||||
private readonly walletService: WalletService,
|
private readonly walletService: WalletService,
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
private readonly userRestaurantRepository: UserRestaurantRepository,
|
private readonly userRestaurantRepository: UserRestaurantRepository,
|
||||||
|
private readonly userGroupService: UserGroupService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async findOrCreateByPhone(phone: string): Promise<User> {
|
async findOrCreateByPhone(phone: string): Promise<User> {
|
||||||
@@ -189,6 +191,10 @@ export class UserService {
|
|||||||
this.em.persist(userRestaurant);
|
this.em.persist(userRestaurant);
|
||||||
await this.em.flush();
|
await this.em.flush();
|
||||||
|
|
||||||
|
if (dto.groupIds?.length) {
|
||||||
|
await this.userGroupService.assignUserToGroups(restId, user.id, dto.groupIds);
|
||||||
|
}
|
||||||
|
|
||||||
await this.em.populate(userRestaurant, ['user']);
|
await this.em.populate(userRestaurant, ['user']);
|
||||||
return userRestaurant;
|
return userRestaurant;
|
||||||
}
|
}
|
||||||
@@ -202,7 +208,26 @@ export class UserService {
|
|||||||
throw new NotFoundException(`User with ID ${userId} not found.`);
|
throw new NotFoundException(`User with ID ${userId} not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.updateUser(userId, restId, dto);
|
const { groupIds, ...userDto } = dto;
|
||||||
|
const user = await this.updateUser(userId, restId, userDto);
|
||||||
|
|
||||||
|
if (groupIds !== undefined) {
|
||||||
|
await this.userGroupService.syncUserGroups(restId, userId, groupIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserGroups(userId: string, restId: string) {
|
||||||
|
const userRestaurant = await this.userRestaurantRepository.findOne({
|
||||||
|
user: { id: userId },
|
||||||
|
restaurant: { id: restId },
|
||||||
|
});
|
||||||
|
if (!userRestaurant) {
|
||||||
|
throw new NotFoundException(`User with ID ${userId} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.userGroupService.getGroupsForUser(restId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUser(userId: string, restId: string, dto: UpdateUserDto): Promise<User> {
|
async updateUser(userId: string, restId: string, dto: UpdateUserDto): Promise<User> {
|
||||||
@@ -211,15 +236,16 @@ export class UserService {
|
|||||||
throw new NotFoundException(`User with ID ${userId} not found.`);
|
throw new NotFoundException(`User with ID ${userId} not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { groupIds: _groupIds, ...userFields } = dto;
|
||||||
|
|
||||||
// Normalize date strings into Date objects if provided
|
// Normalize date strings into Date objects if provided
|
||||||
const assignData: Partial<User> = { ...dto } as Partial<User>;
|
const assignData: Partial<User> = { ...userFields } as Partial<User>;
|
||||||
if (dto.birthDate) {
|
if (userFields.birthDate) {
|
||||||
assignData.birthDate = new Date(dto.birthDate);
|
assignData.birthDate = new Date(userFields.birthDate);
|
||||||
}
|
}
|
||||||
if (dto.marriageDate) {
|
if (userFields.marriageDate) {
|
||||||
assignData.marriageDate = new Date(dto.marriageDate);
|
assignData.marriageDate = new Date(userFields.marriageDate);
|
||||||
}
|
}
|
||||||
// get restuarant
|
|
||||||
this.em.assign(user, assignData);
|
this.em.assign(user, assignData);
|
||||||
await this.em.flush();
|
await this.em.flush();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user