This commit is contained in:
2026-01-14 11:29:24 +03:30
parent 1075dbc44f
commit 4ab9de447a
26 changed files with 1130 additions and 1710 deletions
+7 -5
View File
@@ -54,7 +54,7 @@ export class AdminAuthGuard implements CanActivate {
secret, secret,
}); });
if (!payload.adminId || !payload.) { if (!payload.adminId) {
this.logger.error('Invalid token payload structure', payload); this.logger.error('Invalid token payload structure', payload);
throw new UnauthorizedException('Invalid token payload'); throw new UnauthorizedException('Invalid token payload');
} }
@@ -69,19 +69,21 @@ export class AdminAuthGuard implements CanActivate {
if (!requiredPermissions || requiredPermissions.length === 0) { if (!requiredPermissions || requiredPermissions.length === 0) {
return true; return true;
} }
//TODO : admin permissions must get it from cache
const adminPermission = (await this.permissionsService.getAdminPermissions(payload.adminId))
const adminPermission = await this.permissionsService.getAdminPermissions(payload.adminId, payload.);
if (!adminPermission || !Array.isArray(adminPermission)) { if (!adminPermission || !Array.isArray(adminPermission)) {
this.logger.error('No permissions found', { adminId: payload.adminId, : payload. }); this.logger.error('No permissions found', { adminId: payload.adminId });
throw new ForbiddenException('No permissions found'); throw new ForbiddenException('No permissions found');
} }
const hasPermission = requiredPermissions.every(p => adminPermission.includes(p)); const adminPermissionNames = adminPermission.map(p => p.name)
const hasPermission = requiredPermissions.every(p => adminPermissionNames.includes(p));
if (!hasPermission) { if (!hasPermission) {
this.logger.warn('Insufficient permissions', { this.logger.warn('Insufficient permissions', {
adminId: payload.adminId, adminId: payload.adminId,
: payload.,
required: requiredPermissions, required: requiredPermissions,
has: adminPermission, has: adminPermission,
}); });
+1 -1
View File
@@ -34,7 +34,7 @@ export class AuthGuard implements CanActivate {
const payload = await this.jwtService.verifyAsync<ITokenPayload>(token, { const payload = await this.jwtService.verifyAsync<ITokenPayload>(token, {
secret, secret,
}); });
if (!payload.userId || !payload.) { if (!payload.userId ) {
this.logger.error('Invalid token payload structure', payload); this.logger.error('Invalid token payload structure', payload);
throw new UnauthorizedException('Invalid token payload'); throw new UnauthorizedException('Invalid token payload');
} }
@@ -5,13 +5,10 @@ import { NotificationPreferenceService } from '../services/notification-preferen
import { AuthGuard } from '../../auth/guards/auth.guard'; import { AuthGuard } from '../../auth/guards/auth.guard';
import { UserId } from '../../../common/decorators/user-id.decorator'; import { UserId } from '../../../common/decorators/user-id.decorator';
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard'; import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
import { } from '../../../common/decorators/rest-id.decorator';
import { UpdatePreferenceDto } from '../dto/update-preference.dto'; import { UpdatePreferenceDto } from '../dto/update-preference.dto';
import { CreatePreferenceDto } from '../dto/create-preference.dto'; import { CreatePreferenceDto } from '../dto/create-preference.dto';
import { AdminId } from 'src/common/decorators/admin-id.decorator'; import { AdminId } from 'src/common/decorators/admin-id.decorator';
import { API_HEADER_SLUG } from 'src/common/constants/index';
import { NotificationMessage } from 'src/common/enums/message.enum'; import { NotificationMessage } from 'src/common/enums/message.enum';
import { SuperAdminAuthGuard } from 'src/modules/auth/guards/superAdminAuth.guard';
import { Permissions } from 'src/common/decorators/permissions.decorator'; import { Permissions } from 'src/common/decorators/permissions.decorator';
import { Permission } from 'src/common/enums/permission.enum'; import { Permission } from 'src/common/enums/permission.enum';
@@ -23,174 +20,156 @@ export class NotificationsController {
private readonly preferenceService: NotificationPreferenceService, private readonly preferenceService: NotificationPreferenceService,
) { } ) { }
@UseGuards(AuthGuard) // @UseGuards(AuthGuard)
@ApiBearerAuth() // @ApiBearerAuth()
@Get('public/notifications') // @Get('public/notifications')
@ApiOperation({ summary: 'Get user restaurant notifications' }) // @ApiOperation({ summary: 'Get user restaurant notifications' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of items to return (default: 50)' }) // @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of items to return (default: 50)' })
@ApiQuery({ // @ApiQuery({
name: 'cursor', // name: 'cursor',
required: false, // required: false,
type: String, // type: String,
description: 'Cursor for pagination (ID of the last item from previous page)', // description: 'Cursor for pagination (ID of the last item from previous page)',
}) // })
@ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' }) // @ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' })
async getUserNotifications( // async getUserNotifications(
@UserId() userId: string, // @UserId() userId: string,
, // @Query('limit') limit?: number,
@Query('limit') limit?: number, // @Query('cursor') cursor?: string,
@Query('cursor') cursor?: string, // @Query('status') status?: 'seen' | 'unseen',
@Query('status') status?: 'seen' | 'unseen', // ) {
) { // return await this.notificationService.findByUserAndRestaurant(
return await this.notificationService.findByUserAndRestaurant( // userId,
userId, // limit ? parseInt(limit.toString(), 10) : 50,
, // cursor,
limit ? parseInt(limit.toString(), 10) : 50, // status,
cursor, // );
status, // }
);
}
@UseGuards(AuthGuard) // @UseGuards(AuthGuard)
@ApiBearerAuth() // @ApiBearerAuth()
@Get('public/notifications/unseen-count') // @Get('public/notifications/unseen-count')
@ApiOperation({ summary: 'Get unseen notifications count for user' }) // @ApiOperation({ summary: 'Get unseen notifications count for user' })
async getUserUnseenCount(@UserId() userId: string,) { // async getUserUnseenCount(@UserId() userId: string,) {
const count = await this.notificationService.countUnseenByUserAndRestaurant(userId,); // const count = await this.notificationService.countUnseenByUserAndRestaurant(userId,);
return { count }; // return { count };
} // }
@UseGuards(AuthGuard) // @UseGuards(AuthGuard)
@ApiBearerAuth() // @ApiBearerAuth()
@Put('public/notifications/:id') // @Put('public/notifications/:id')
@ApiOperation({ summary: 'Read a notification ' }) // @ApiOperation({ summary: 'Read a notification ' })
@ApiParam({ name: 'id', description: 'Notification ID' }) // @ApiParam({ name: 'id', description: 'Notification ID' })
async readNotificationUser(, @Param('id') id: string, @UserId() userId: string) { // async readNotificationUser(, @Param('id') id: string, @UserId() userId: string) {
await this.notificationService.readNotificationAsUser(id, userId,); // await this.notificationService.readNotificationAsUser(id, userId,);
return { message: NotificationMessage.READ_SUCCESS }; // return { message: NotificationMessage.READ_SUCCESS };
} // }
@UseGuards(AuthGuard) // @UseGuards(AuthGuard)
@ApiBearerAuth() // @ApiBearerAuth()
@Put('public/notifications/read/all') // @Put('public/notifications/read/all')
@ApiOperation({ summary: 'Read all notification ' }) // @ApiOperation({ summary: 'Read all notification ' })
async readAllNotificationUser(, @UserId() userId: string) { // async readAllNotificationUser(, @UserId() userId: string) {
await this.notificationService.readAllNotifsAsUser(userId,); // await this.notificationService.readAllNotifsAsUser(userId,);
return { message: NotificationMessage.READ_SUCCESS }; // return { message: NotificationMessage.READ_SUCCESS };
} // }
/* ***************** Admin Endpoints ***************** */ // /* ***************** Admin Endpoints ***************** */
@UseGuards(AdminAuthGuard) // @UseGuards(AdminAuthGuard)
@ApiBearerAuth() // @ApiBearerAuth()
@Get('admin/notifications') // @Get('admin/notifications')
@ApiOperation({ summary: 'Get Admin notifications' }) // @ApiOperation({ summary: 'Get Admin notifications' })
@ApiQuery({ name: 'limit', required: false, type: Number }) // @ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ // @ApiQuery({
name: 'cursor', // name: 'cursor',
required: false, // required: false,
type: String, // type: String,
description: 'Cursor for pagination (ID of the last item from previous page)', // description: 'Cursor for pagination (ID of the last item from previous page)',
}) // })
@ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' }) // @ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' })
async getAdminNotifications( // async getAdminNotifications(
@AdminId() adminId: string, // @AdminId() adminId: string,
, // ,
@Query('limit') limit?: number, // @Query('limit') limit?: number,
@Query('cursor') cursor?: string, // @Query('cursor') cursor?: string,
@Query('status') status?: 'seen' | 'unseen', // @Query('status') status?: 'seen' | 'unseen',
) { // ) {
const parsedLimit = limit ? parseInt(limit.toString(), 10) : 50; // const parsedLimit = limit ? parseInt(limit.toString(), 10) : 50;
return await this.notificationService.findByRestaurant(, adminId, parsedLimit, cursor, status); // return await this.notificationService.findByRestaurant( adminId, parsedLimit, cursor, status);
} // }
@UseGuards(AdminAuthGuard) // @UseGuards(AdminAuthGuard)
@ApiBearerAuth() // @ApiBearerAuth()
@Get('admin/notifications/unseen-count') // @Get('admin/notifications/unseen-count')
@ApiOperation({ summary: 'Get unseen notifications count for admin' }) // @ApiOperation({ summary: 'Get unseen notifications count for admin' })
async getAdminUnseenCount(@AdminId() adminId: string,) { // async getAdminUnseenCount(@AdminId() adminId: string,) {
const count = await this.notificationService.countUnseenByRestaurant(adminId,); // const count = await this.notificationService.countUnseenByRestaurant(adminId,);
return { count }; // return { count };
} // }
@UseGuards(AdminAuthGuard) // @UseGuards(AdminAuthGuard)
@ApiBearerAuth() // @ApiBearerAuth()
@Put('admin/notifications/:id') // @Put('admin/notifications/:id')
@ApiOperation({ summary: 'Read a notification ' }) // @ApiOperation({ summary: 'Read a notification ' })
@ApiParam({ name: 'id', description: 'Notification ID' }) // @ApiParam({ name: 'id', description: 'Notification ID' })
async readNotificationAdmin(, @AdminId() adminId: string, @Param('id') id: string) { // async readNotificationAdmin(, @AdminId() adminId: string, @Param('id') id: string) {
await this.notificationService.readNotificationAdmin(id, adminId,); // await this.notificationService.readNotificationAdmin(id, adminId,);
return { message: NotificationMessage.READ_SUCCESS }; // return { message: NotificationMessage.READ_SUCCESS };
} // }
@UseGuards(AdminAuthGuard) // @UseGuards(AdminAuthGuard)
@ApiBearerAuth() // @ApiBearerAuth()
@Put('admin/notifications/read/all') // @Put('admin/notifications/read/all')
@ApiOperation({ summary: 'Read all notification ' }) // @ApiOperation({ summary: 'Read all notification ' })
async readAllNotificationAdmin(, @AdminId() adminId: string, @Param('id') id: string) { // async readAllNotificationAdmin(, @AdminId() adminId: string, @Param('id') id: string) {
await this.notificationService.readAllNotifsAsAdmin(adminId,); // await this.notificationService.readAllNotifsAsAdmin(adminId,);
return { message: NotificationMessage.READ_SUCCESS }; // return { message: NotificationMessage.READ_SUCCESS };
} // }
@UseGuards(AdminAuthGuard) // @UseGuards(AdminAuthGuard)
@ApiBearerAuth() // @ApiBearerAuth()
@Permissions(Permission.MANAGE_SETTINGS) // @Permissions(Permission.MANAGE_SETTINGS)
@Get('admin/notification-preferences') // @Get('admin/notification-preferences')
@ApiOperation({ summary: 'Get all notification preferences for a restaurant' }) // @ApiOperation({ summary: 'Get all notification preferences for a restaurant' })
async getPreferences() { // async getPreferences() {
return this.preferenceService.findByRestaurant(); // return this.preferenceService.findByRestaurant();
} // }
@UseGuards(AdminAuthGuard) // @UseGuards(AdminAuthGuard)
@ApiBearerAuth() // @ApiBearerAuth()
@Permissions(Permission.MANAGE_SETTINGS) // @Permissions(Permission.MANAGE_SETTINGS)
@Patch('admin/notification-preferences/:id') // @Patch('admin/notification-preferences/:id')
@ApiOperation({ summary: 'Update notification channels' }) // @ApiOperation({ summary: 'Update notification channels' })
@ApiParam({ name: 'id', description: 'Notification preference ID' }) // @ApiParam({ name: 'id', description: 'Notification preference ID' })
async updatePreference( // async updatePreference(
, // ,
@Param('id') preferenceId: string, // @Param('id') preferenceId: string,
@Body() dto: UpdatePreferenceDto, // @Body() dto: UpdatePreferenceDto,
) { // ) {
return this.preferenceService.updatePreference(preferenceId, , dto); // return this.preferenceService.updatePreference(preferenceId, dto);
} // }
@UseGuards(AdminAuthGuard) // @UseGuards(AdminAuthGuard)
@ApiBearerAuth() // @ApiBearerAuth()
@Permissions(Permission.MANAGE_SETTINGS) // @Permissions(Permission.MANAGE_SETTINGS)
@Post('admin/notification-preferences') // @Post('admin/notification-preferences')
@ApiOperation({ summary: 'Create notification preference' }) // @ApiOperation({ summary: 'Create notification preference' })
async createPreference( // async createPreference(
, // ,
@Body() dto: CreatePreferenceDto, // @Body() dto: CreatePreferenceDto,
) { // ) {
return this.preferenceService.create(, dto); // return this.preferenceService.create(dto);
} // }
@UseGuards(AdminAuthGuard) // @UseGuards(AdminAuthGuard)
@ApiBearerAuth() // @ApiBearerAuth()
@Get('admin/notifications/sms-usage') // @Get('admin/notifications/sms-usage')
@ApiOperation({ summary: 'Get SMS usage for my restaurant' }) // @ApiOperation({ summary: 'Get SMS usage for my restaurant' })
async getRestaurantSmsUsage() { // async getRestaurantSmsUsage() {
const smsCount = await this.notificationService.getSmsCountBy(); // const smsCount = await this.notificationService.getSmsCountBy();
return { smsCount }; // return { smsCount };
} // }
// super admin endpoints
@UseGuards(SuperAdminAuthGuard)
@ApiBearerAuth()
@Get('super-admin/notifications/sms-count')
@ApiOperation({ summary: 'Get SMS count for each restaurant with pagination' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)', minimum: 1 })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 10)', minimum: 1 })
async getSmsCount(
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
const parsedPage = page ? parseInt(page.toString(), 10) : 1;
const parsedLimit = limit ? parseInt(limit.toString(), 10) : 10;
return await this.notificationService.getSmsCountByRestaurant(parsedPage, parsedLimit);
}
} }
@@ -1,26 +0,0 @@
import type { ExecutionContext } from '@nestjs/common';
import { createParamDecorator } from '@nestjs/common';
import type { AuthenticatedSocket } from '../guards/ws-admin-auth.guard';
/**
* Extract restaurant ID from authenticated WebSocket client
* Use this decorator in WebSocket handlers to get the from the authenticated admin
*
* @example
* ```typescript
* @SubscribeMessage('get:notifications')
* handleGetNotifications(@Ws() : string) {
* // is automatically extracted from authenticated admin
* }
* ```
*/
export const Ws = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
const client = ctx.switchToWs().getClient<AuthenticatedSocket>();
const = client.;
if (!) {
throw new Error('Restaurant ID not found. Ensure WsAdminAuthGuard is applied.');
}
return;
});
@@ -1,14 +1,10 @@
import { Entity, Property, ManyToOne, Unique } from '@mikro-orm/core'; import { Entity, Property, ManyToOne, Unique } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity'; import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { NotifTitleEnum } from '../interfaces/notification.interface'; import { NotifTitleEnum } from '../interfaces/notification.interface';
import { NotifChannelEnum } from '../interfaces/notification.interface'; import { NotifChannelEnum } from '../interfaces/notification.interface';
@Entity({ tableName: 'notification_preferences' }) @Entity({ tableName: 'notification_preferences' })
@Unique({ properties: ['restaurant', 'title'] })
export class NotificationPreference extends BaseEntity { export class NotificationPreference extends BaseEntity {
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@Property() @Property()
title!: NotifTitleEnum; title!: NotifTitleEnum;
@@ -1,14 +1,10 @@
import { Entity, Property, ManyToOne, PrimaryKey } from '@mikro-orm/core'; import { Entity, Property, PrimaryKey } from '@mikro-orm/core';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
@Entity({ tableName: 'sms_logs' }) @Entity({ tableName: 'sms_logs' })
export class SmsLog { export class SmsLog {
@PrimaryKey() @PrimaryKey()
id!: number; id!: number;
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@Property() @Property()
phone!: string; phone!: string;
@@ -7,7 +7,6 @@ import { IAdminTokenPayload } from '../../auth/interfaces/IToken-payload';
export interface AuthenticatedSocket extends Socket { export interface AuthenticatedSocket extends Socket {
adminId?: string; adminId?: string;
?: string;
} }
@Injectable() @Injectable()
@@ -41,16 +40,15 @@ export class WsAdminAuthGuard implements CanActivate {
secret, secret,
}); });
if (!payload.adminId || !payload.) { if (!payload.adminId) {
this.logger.error('Invalid token payload structure', payload); this.logger.error('Invalid token payload structure', payload);
throw new WsException('Invalid token payload'); throw new WsException('Invalid token payload');
} }
// Attach admin info to socket // Attach admin info to socket
(client as AuthenticatedSocket).adminId = payload.adminId; (client as AuthenticatedSocket).adminId = payload.adminId;
(client as AuthenticatedSocket). = payload.;
this.logger.log(`Admin authenticated via WebSocket: ${payload.adminId}, : ${payload.}`); this.logger.log(`Admin authenticated via WebSocket: ${payload.adminId}`);
return true; return true;
} catch (error) { } catch (error) {
@@ -3,7 +3,6 @@ import type { NotifTitleEnum, recipientType } from './notification.interface';
export interface SmsNotificationQueueJob { export interface SmsNotificationQueueJob {
recipient: recipientType; recipient: recipientType;
templateId: string; templateId: string;
: string;
parameters?: Record<string, string>; parameters?: Record<string, string>;
} }
export interface PushNotificationQueueJob { export interface PushNotificationQueueJob {
@@ -18,7 +17,7 @@ export interface PushNotificationQueueJob {
pushTokens?: string[]; // Multiple FCM tokens for bulk push notifications pushTokens?: string[]; // Multiple FCM tokens for bulk push notifications
} }
export interface InAppNotificationQueueJob { export interface InAppNotificationQueueJob {
recipient: { adminId: string; : string }; recipient: { adminId: string; };
subject: NotifTitleEnum; subject: NotifTitleEnum;
body: string; body: string;
notificationId: string; notificationId: string;
@@ -40,7 +40,6 @@ export interface NotifRequest {
// timestamp: Date; // timestamp: Date;
// notifType: NotifTypeEnum; // notifType: NotifTypeEnum;
// channels: NotifChannelEnum[]; // channels: NotifChannelEnum[];
: string;
recipients: recipientType[]; recipients: recipientType[];
message: NotifRequestMessage; message: NotifRequestMessage;
metadata: { metadata: {
@@ -1,7 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { SmsSentEvent } from '../events/sms.events'; import { SmsSentEvent } from '../events/sms.events';
import { RestRepository } from '../../restaurants/repositories/rest.repository';
import { EntityManager } from '@mikro-orm/postgresql'; import { EntityManager } from '@mikro-orm/postgresql';
import { SmsLog } from '../entities/smsLogs.entity'; import { SmsLog } from '../entities/smsLogs.entity';
@@ -10,7 +9,6 @@ export class SmsListeners {
private readonly logger = new Logger(SmsListeners.name); private readonly logger = new Logger(SmsListeners.name);
constructor( constructor(
private readonly restRepository: RestRepository,
private readonly em: EntityManager, private readonly em: EntityManager,
) { ) {
} }
@@ -19,22 +17,11 @@ export class SmsListeners {
async handleSmsSent(event: SmsSentEvent) { async handleSmsSent(event: SmsSentEvent) {
try { try {
this.logger.log( this.logger.log(
`SMS sent event received: phone ${event.phoneNumber} for restaurant: ${event.}`, `SMS sent event received: phone ${event.phoneNumber} `,
); );
// Get the restaurant entity
const restaurant = await this.restRepository.findOne({ id: event. });
if (!restaurant) {
this.logger.warn(
`Restaurant not found for SMS log: ${event.}`,
);
return;
}
// Create SMS log record // Create SMS log record
const smsLog = this.em.create(SmsLog, { const smsLog = this.em.create(SmsLog, {
restaurant: restaurant,
phone: event.phoneNumber, phone: event.phoneNumber,
createdAt: new Date(), createdAt: new Date(),
}); });
@@ -46,7 +33,7 @@ export class SmsListeners {
); );
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Failed to create SMS log for event: ${event.}`, `Failed to create SMS `,
error instanceof Error ? error.stack : String(error), error instanceof Error ? error.stack : String(error),
); );
} }
@@ -4,8 +4,6 @@ import {
// SubscribeMessage, // SubscribeMessage,
OnGatewayConnection, OnGatewayConnection,
OnGatewayDisconnect, OnGatewayDisconnect,
// MessageBody,
// ConnectedSocket,
} from '@nestjs/websockets'; } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import { Logger, Inject, forwardRef } from '@nestjs/common'; import { Logger, Inject, forwardRef } from '@nestjs/common';
@@ -70,8 +68,8 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
// } // }
} }
sendInAppNotification(repipient: { adminId: string; : string }, payload: IInAppNotificationPayload) { sendInAppNotification(repipient: { adminId: string; }, payload: IInAppNotificationPayload) {
const room = this.getRoom(repipient.adminId, repipient.); const room = this.getRoom(repipient.adminId,);
this.logger.log(`Sending in app notification to admin: ${repipient.adminId}`); 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 () => {
@@ -81,19 +79,19 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
this.logger.log(`In app notification sent to admin: ${repipient.adminId}`); this.logger.log(`In app notification sent to admin: ${repipient.adminId}`);
} }
private getRoom(adminId: string, : string) { private getRoom(adminId: string,) {
return `restaurant:${}-admin:${adminId}`; return `admin:${adminId}`;
} }
handleLeaveRoom(client: AuthenticatedSocket) { handleLeaveRoom(client: AuthenticatedSocket) {
const room = this.getRoom(client.adminId!, client.!); const room = this.getRoom(client.adminId!,);
void client.leave(room); void client.leave(room);
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) left room: ${room}`); this.logger.log(`Admin ${client.adminId} (Client ${client.id}) left room: ${room}`);
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.!); const room = this.getRoom(client.adminId!,);
void client.join(room); void client.join(room);
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) joined room: ${room}`); this.logger.log(`Admin ${client.adminId} (Client ${client.id}) joined room: ${room}`);
void client.emit('joined', { room, message: 'Successfully Admin joined room' }); void client.emit('joined', { room, message: 'Successfully Admin joined room' });
@@ -9,69 +9,5 @@ export class SmsLogRepository extends EntityRepository<SmsLog> {
super(em, SmsLog); super(em, SmsLog);
} }
async getSmsCountByRestaurant(
page: number = 1,
limit: number = 10,
): Promise<PaginatedResult<{ : string; restaurantName: string; smsCount: number
}>> {
const offset = (page - 1) * limit;
// Get total count of unique restaurants with SMS logs
const totalResult = await this.em.execute(
`
SELECT COUNT(DISTINCT sl.restaurant_id) as total
FROM sms_logs sl
WHERE sl.restaurant_id IS NOT NULL
`,
);
const total = parseInt(totalResult[0]?.total || '0', 10);
// Get paginated results with SMS counts grouped by restaurant
const results = await this.em.execute(
`
SELECT
r.id as "",
r.name as "restaurantName",
COUNT(sl.id)::int as "smsCount"
FROM sms_logs sl
INNER JOIN restaurants r ON sl.restaurant_id = r.id
GROUP BY r.id, r.name
ORDER BY "smsCount" DESC, r.name ASC
LIMIT ? OFFSET ?
`,
[limit, offset],
);
const data = results.map((row: any) => ({
: row.,
restaurantName: row.restaurantName || 'Unknown',
smsCount: parseInt(row.smsCount || '0', 10),
}));
const totalPages = Math.ceil(total / limit);
return {
data,
meta: {
total,
page,
limit,
totalPages,
},
};
}
async getSmsCountBy(: string): Promise < number > {
const result = await this.em.execute(
`
SELECT COUNT(sl.id)::int as "smsCount"
FROM sms_logs sl
WHERE sl.restaurant_id = ?
`,
[],
);
return parseInt(result[0]?.smsCount || '0', 10);
}
} }
@@ -1,7 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql'; import { EntityManager } from '@mikro-orm/postgresql';
import { NotificationPreference } from '../entities/notification-preference.entity'; import { NotificationPreference } from '../entities/notification-preference.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { CreatePreferenceDto } from '../dto/create-preference.dto'; import { CreatePreferenceDto } from '../dto/create-preference.dto';
import { UpdatePreferenceDto } from '../dto/update-preference.dto'; import { UpdatePreferenceDto } from '../dto/update-preference.dto';
import { NotifTitleEnum } from '../interfaces/notification.interface'; import { NotifTitleEnum } from '../interfaces/notification.interface';
@@ -10,34 +9,34 @@ import { NotifTitleEnum } from '../interfaces/notification.interface';
export class NotificationPreferenceService { export class NotificationPreferenceService {
constructor(private readonly em: EntityManager) { } constructor(private readonly em: EntityManager) { }
async create(: string, dto: CreatePreferenceDto): Promise<NotificationPreference> { // async create( dto: CreatePreferenceDto): Promise<NotificationPreference> {
const restaurant = await this.em.findOne(Restaurant, { id: }); // const restaurant = await this.em.findOne(Restaurant, { id: });
if (!restaurant) { // if (!restaurant) {
throw new NotFoundException('Restaurant not found'); // throw new NotFoundException('Restaurant not found');
} // }
const preference = this.em.create(NotificationPreference, { // const preference = this.em.create(NotificationPreference, {
restaurant, // restaurant,
channels: dto.channels, // channels: dto.channels,
title: dto.title, // title: dto.title,
}); // });
await this.em.persistAndFlush(preference); // await this.em.persistAndFlush(preference);
return preference; // return preference;
} // }
async findByRestaurant(: string): Promise<NotificationPreference[]> { // async findByRestaurant(: string): Promise<NotificationPreference[]> {
return this.em.find(NotificationPreference, { // return this.em.find(NotificationPreference, {
restaurant: { id: }, // restaurant: { id: },
}); // });
} // }
async findByRestaurantAndType(: string, title: NotifTitleEnum): Promise<NotificationPreference | null> { // async findByRestaurantAndType(: string, title: NotifTitleEnum): Promise<NotificationPreference | null> {
return this.em.findOne(NotificationPreference, { // return this.em.findOne(NotificationPreference, {
restaurant: { id: }, // restaurant: { id: },
title, // title,
}); // });
} // }
// async updateEnabled( // async updateEnabled(
// : string, // : string,
@@ -54,32 +53,32 @@ export class NotificationPreferenceService {
// return preference; // return preference;
// } // }
async updatePreference( // async updatePreference(
preferenceId: string, // preferenceId: string,
: string, // : string,
dto: UpdatePreferenceDto, // dto: UpdatePreferenceDto,
): Promise<NotificationPreference> { // ): Promise<NotificationPreference> {
const preference = await this.em.findOne(NotificationPreference, { // const preference = await this.em.findOne(NotificationPreference, {
id: preferenceId, // id: preferenceId,
restaurant: { id: }, // restaurant: { id: },
}); // });
if (!preference) { // if (!preference) {
throw new NotFoundException('Notification preference not found'); // throw new NotFoundException('Notification preference not found');
} // }
this.em.assign(preference, dto); // this.em.assign(preference, dto);
await this.em.persistAndFlush(preference); // await this.em.persistAndFlush(preference);
return preference; // return preference;
} // }
async delete(: string, preferenceId: string): Promise<void> { // async delete(: string, preferenceId: string): Promise<void> {
const preference = await this.em.findOne(NotificationPreference, { // const preference = await this.em.findOne(NotificationPreference, {
restaurant: { id: }, // restaurant: { id: },
id: preferenceId, // id: preferenceId,
}); // });
if (!preference) { // if (!preference) {
throw new NotFoundException('Notification preference not found'); // throw new NotFoundException('Notification preference not found');
} // }
await this.em.removeAndFlush(preference); // await this.em.removeAndFlush(preference);
} // }
} }
@@ -21,259 +21,259 @@ export class NotificationService {
private readonly smsLogRepository: SmsLogRepository, private readonly smsLogRepository: SmsLogRepository,
) { } ) { }
async sendNotification(params: NotifRequest): Promise<Notification[]> { // async sendNotification(params: NotifRequest): Promise<Notification[]> {
const { recipients, message, metadata, } = params; // const { recipients, message, metadata, } = params;
// create Database notifications // // create Database notifications
const notifications = await this.createAdminBulkNotifications( // const notifications = await this.createAdminBulkNotifications(
recipients.map(recipient => ({ // recipients.map(recipient => ({
title: message.title, // title: message.title,
content: message.content, // content: message.content,
adminId: 'adminId' in recipient ? recipient.adminId : null, // adminId: 'adminId' in recipient ? recipient.adminId : null,
userId: 'userId' in recipient ? recipient.userId : null, // userId: 'userId' in recipient ? recipient.userId : null,
})), // })),
); // );
// get admin prefrences // // get admin prefrences
const preference = await this.preferenceService.findByRestaurantAndType(, message.title); // const preference = await this.preferenceService.findByRestaurantAndType(, message.title);
if(preference?.channels?.length === 0) { // if(preference?.channels?.length === 0) {
this.logger.warn(`Notification type is NONE for restaurant ${}, title ${message.title}`); // this.logger.warn(`Notification type is NONE for restaurant ${}, title ${message.title}`);
return notifications; // return notifications;
} // }
// send in app notification // // send in app notification
if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) { // if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) {
await this.queueService.addBulkInAppNotifications( // await this.queueService.addBulkInAppNotifications(
notifications.map(notification => ({ // notifications.map(notification => ({
recipient: { adminId: notification.admin?.id || '', }, // recipient: { adminId: notification.admin?.id || '', },
subject: message.title, // subject: message.title,
body: message.content, // body: message.content,
notificationId: notification.id, // notificationId: notification.id,
})), // })),
); // );
} // }
// 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,
templateId: message.sms.templateId, // templateId: message.sms.templateId,
parameters: message.sms.parameters, // parameters: message.sms.parameters,
})), // })),
); // );
} // }
this.logger.log(`Queued notification for restaurant ${}, title ${message.title}`); // this.logger.log(`Queued notification for restaurant ${}, title ${message.title}`);
return notifications; // return notifications;
} // }
async createAdminBulkNotifications( // async createAdminBulkNotifications(
params: { // params: {
title: NotifTitleEnum; // title: NotifTitleEnum;
content: string; // content: string;
adminId: string | null; // adminId: string | null;
userId: string | null; // userId: string | null;
}[], // }[],
): Promise < Notification[] > { // ): Promise < Notification[] > {
const notifications = params.map(param => { // const notifications = params.map(param => {
return this.em.create(Notification, { // return this.em.create(Notification, {
admin: param.adminId, // admin: param.adminId,
user: param.userId && param.userId.trim() !== '' ? param.userId : null, // user: param.userId && param.userId.trim() !== '' ? param.userId : null,
title: param.title, // title: param.title,
content: param.content, // content: param.content,
}); // });
}); // });
await this.em.persistAndFlush(notifications); // await this.em.persistAndFlush(notifications);
return notifications; // return notifications;
} // }
async findOne(id: string): Promise < Notification > { // async findOne(id: string): Promise < Notification > {
const notification = await this.em.findOne(Notification, { id }, { populate: ['restaurant', 'user'] }); // const notification = await this.em.findOne(Notification, { id }, { populate: ['restaurant', 'user'] });
if(!notification) { // if(!notification) {
throw new NotFoundException('Notification not found'); // throw new NotFoundException('Notification not found');
} // }
return notification; // return notification;
} // }
async findByRestaurant( // async findByRestaurant(
: string, // : string,
adminId: string, // adminId: string,
limit = 50, // limit = 50,
cursor ?: string, // cursor ?: string,
status ?: 'seen' | 'unseen', // status ?: 'seen' | 'unseen',
): Promise < { data: Notification[]; nextCursor: string | null } > { // ): Promise < { data: Notification[]; nextCursor: string | null } > {
const where: FilterQuery<Notification> = { // const where: FilterQuery<Notification> = {
restaurant: { id: }, // restaurant: { id: },
admin: { id: adminId }, // admin: { id: adminId },
}; // };
// Filter by status (seen/unseen) // // Filter by status (seen/unseen)
if (status === 'seen') { // if (status === 'seen') {
where.seenAt = { $ne: null }; // where.seenAt = { $ne: null };
} else if (status === 'unseen') { // } else if (status === 'unseen') {
where.seenAt = null; // where.seenAt = null;
} // }
// Cursor-based pagination: if cursor is provided, get items with id < cursor // // Cursor-based pagination: if cursor is provided, get items with id < cursor
if (cursor) { // if (cursor) {
where.id = { $lt: cursor }; // where.id = { $lt: cursor };
} // }
const notifications = await this.em.find(Notification, where, { // const notifications = await this.em.find(Notification, where, {
orderBy: { createdAt: 'DESC', id: 'DESC' }, // orderBy: { createdAt: 'DESC', id: 'DESC' },
limit: limit + 1, // fetch one extra to determine next page // limit: limit + 1, // fetch one extra to determine next page
populate: ['user'], // populate: ['user'],
}); // });
const hasNextPage = notifications.length > limit; // const hasNextPage = notifications.length > limit;
const data = hasNextPage ? notifications.slice(0, limit) : notifications; // const data = hasNextPage ? notifications.slice(0, limit) : notifications;
const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null; // const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
return { // return {
data, // data,
nextCursor: nextCursor ?? null, // nextCursor: nextCursor ?? null,
}; // };
} // }
async findByUserAndRestaurant( // async findByUserAndRestaurant(
userId: string, // userId: string,
: string, // : string,
limit = 50, // limit = 50,
cursor ?: string, // cursor ?: string,
status ?: 'seen' | 'unseen', // status ?: 'seen' | 'unseen',
): Promise < { data: Notification[]; nextCursor: string | null } > { // ): Promise < { data: Notification[]; nextCursor: string | null } > {
const where: FilterQuery<Notification> = { // const where: FilterQuery<Notification> = {
user: { id: userId }, // user: { id: userId },
restaurant: { id: }, // restaurant: { id: },
}; // };
// Filter by status (seen/unseen) // // Filter by status (seen/unseen)
if (status === 'seen') { // if (status === 'seen') {
where.seenAt = { $ne: null }; // where.seenAt = { $ne: null };
} else if (status === 'unseen') { // } else if (status === 'unseen') {
where.seenAt = null; // where.seenAt = null;
} // }
// Cursor-based pagination: if cursor is provided, get items with id < cursor (since ULIDs are time-ordered) // // Cursor-based pagination: if cursor is provided, get items with id < cursor (since ULIDs are time-ordered)
if (cursor) { // if (cursor) {
where.id = { $lt: cursor }; // where.id = { $lt: cursor };
} // }
const notifications = await this.em.find(Notification, where, { // const notifications = await this.em.find(Notification, where, {
orderBy: { createdAt: 'DESC', id: 'DESC' }, // orderBy: { createdAt: 'DESC', id: 'DESC' },
limit: limit + 1, // Fetch one extra to determine if there's a next page // limit: limit + 1, // Fetch one extra to determine if there's a next page
}); // });
// Check if there's a next page // // Check if there's a next page
const hasNextPage = notifications.length > limit; // const hasNextPage = notifications.length > limit;
const data = hasNextPage ? notifications.slice(0, limit) : notifications; // const data = hasNextPage ? notifications.slice(0, limit) : notifications;
const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null; // const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
return { // return {
data, // data,
nextCursor: nextCursor ?? null, // nextCursor: nextCursor ?? null,
}; // };
} // }
async readNotificationAdmin(id: string, adminId: string, : string): Promise < void> { // async readNotificationAdmin(id: string, adminId: string, : string): Promise < void> {
const notification = await this.em.findOne(Notification, { // const notification = await this.em.findOne(Notification, {
id, // id,
admin: { id: adminId }, // admin: { id: adminId },
restaurant: { id: }, // restaurant: { id: },
}); // });
if(!notification) { // if(!notification) {
throw new NotFoundException('Notification not found'); // throw new NotFoundException('Notification not found');
} // }
notification.seenAt = new Date(); // notification.seenAt = new Date();
await this.em.persistAndFlush(notification); // await this.em.persistAndFlush(notification);
} // }
async readNotificationAsUser(id: string, userId: string, : string): Promise < void> { // async readNotificationAsUser(id: string, userId: string, : string): Promise < void> {
const notification = await this.em.findOne(Notification, { // const notification = await this.em.findOne(Notification, {
id, // id,
user: { id: userId }, // user: { id: userId },
restaurant: { id: }, // restaurant: { id: },
}); // });
if(!notification) { // if(!notification) {
throw new NotFoundException('Notification not found'); // throw new NotFoundException('Notification not found');
} // }
notification.seenAt = new Date(); // notification.seenAt = new Date();
await this.em.persistAndFlush(notification); // await this.em.persistAndFlush(notification);
} // }
async findByRestaurantAndType(: string, title: NotifTitleEnum, limit = 50): Promise < Notification[] > { // async findByRestaurantAndType(: string, title: NotifTitleEnum, limit = 50): Promise < Notification[] > {
return this.em.find( // return this.em.find(
Notification, // Notification,
{ // {
restaurant: { id: }, // restaurant: { id: },
title, // title,
}, // },
{ // {
orderBy: { createdAt: 'DESC' }, // orderBy: { createdAt: 'DESC' },
limit, // limit,
populate: ['user'], // populate: ['user'],
}, // },
); // );
} // }
async findByAdminAndRestaurant(adminId: string, : string, limit = 50): Promise < Notification[] > { // async findByAdminAndRestaurant(adminId: string, : string, limit = 50): Promise < Notification[] > {
return this.em.find( // return this.em.find(
Notification, // Notification,
{ admin: { id: adminId }, restaurant: { id: } }, // { admin: { id: adminId }, restaurant: { id: } },
{ // {
orderBy: { createdAt: 'DESC' }, // orderBy: { createdAt: 'DESC' },
limit, // limit,
}, // },
); // );
} // }
async countUnseenByUserAndRestaurant(userId: string, : string): Promise < number > { // async countUnseenByUserAndRestaurant(userId: string, : string): Promise < number > {
const where: FilterQuery<Notification> = { // const where: FilterQuery<Notification> = {
user: { id: userId }, // user: { id: userId },
restaurant: { id: }, // restaurant: { id: },
seenAt: null, // seenAt: null,
}; // };
return this.em.count(Notification, where); // return this.em.count(Notification, where);
} // }
async countUnseenByRestaurant(adminId: string, : string): Promise < number > { // async countUnseenByRestaurant(adminId: string, : string): Promise < number > {
const where: FilterQuery<Notification> = { // const where: FilterQuery<Notification> = {
admin: { id: adminId }, // admin: { id: adminId },
restaurant: { id: }, // restaurant: { id: },
seenAt: null, // seenAt: null,
}; // };
return this.em.count(Notification, where); // return this.em.count(Notification, where);
} // }
readAllNotifsAsUser(userId: string, : string): Promise < number > { // readAllNotifsAsUser(userId: string, : string): Promise < number > {
const where: FilterQuery<Notification> = { // const where: FilterQuery<Notification> = {
user: { id: userId }, // user: { id: userId },
restaurant: { id: }, // restaurant: { id: },
seenAt: null, // seenAt: null,
}; // };
return this.em.nativeUpdate(Notification, where, { seenAt: new Date() }); // return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
} // }
readAllNotifsAsAdmin(adminId: string, : string): Promise < number > { // readAllNotifsAsAdmin(adminId: string, : string): Promise < number > {
const where: FilterQuery<Notification> = { // const where: FilterQuery<Notification> = {
admin: { id: adminId }, // admin: { id: adminId },
restaurant: { id: }, // restaurant: { id: },
seenAt: null, // seenAt: null,
}; // };
return this.em.nativeUpdate(Notification, where, { seenAt: new Date() }); // return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
} // }
async getSmsCountByRestaurant( // async getSmsCountByRestaurant(
page: number = 1, // page: number = 1,
limit: number = 10, // limit: number = 10,
): Promise < PaginatedResult < { : string; restaurantName: string; smsCount: number } >> { // ): Promise < PaginatedResult < { : string; restaurantName: string; smsCount: number } >> {
return this.smsLogRepository.getSmsCountByRestaurant(page, limit); // return this.smsLogRepository.getSmsCountByRestaurant(page, limit);
} // }
async getSmsCountBy(: string): Promise < number > { // async getSmsCountBy(: string): Promise < number > {
return this.smsLogRepository.getSmsCountBy(); // return this.smsLogRepository.getSmsCountBy();
} // }
} }
+2 -172
View File
@@ -2,12 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { EntityManager } from '@mikro-orm/postgresql'; import { EntityManager } from '@mikro-orm/postgresql';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Payment } from '../../payment/entities/payment.entity';
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payment/interface/payment';
import { InventoryService } from '../../inventory/inventory.service';
import { OrderStatus } from '../interface/order.interface';
import { Order } from '../entities/order.entity';
import { OrderStatusChangedEvent } from '../events/order.events';
@Injectable() @Injectable()
export class OrdersCrone { export class OrdersCrone {
@@ -15,173 +10,8 @@ export class OrdersCrone {
constructor( constructor(
private readonly em: EntityManager, private readonly em: EntityManager,
private readonly inventoryService: InventoryService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
) { } ) { }
// run every minute and fail pending online payments older than 15 minutes
@Cron('*/1 * * * *', {
name: 'failOldOnlinePayments',
timeZone: 'UTC',
})
async cancelOldOnlinePendingOrders() {
try {
const cutoff = new Date(Date.now() - 15 * 60 * 1000);
this.logger.debug('Searching for pending online payments older than 15 minutes');
const payments = await this.em.find(
Payment,
{
method: PaymentMethodEnum.Online,
status: PaymentStatusEnum.Pending,
createdAt: { $lte: cutoff },
},
{ populate: ['order', 'order.items', 'order.items.product'] },
);
if (!payments || payments.length === 0) {
return;
}
this.logger.log(`Found ${payments.length} stale pending online payments`);
for (const p of payments) {
try {
await this.em.transactional(async em => {
// reload inside transaction to avoid concurrency issues
const payment = await em.findOne(
Payment,
{ id: p.id },
{ populate: ['order', 'order.items', 'order.items.product'] },
);
if (!payment) return;
if (payment.status !== PaymentStatusEnum.Pending) return;
payment.status = PaymentStatusEnum.Failed;
payment.failedAt = new Date();
if (payment.order) {
payment.order.status = OrderStatus.CANCELED;
// prepare restore payload
const items = (payment.order as any).items || [];
const restorePayload = {
items: items.map((it: any) => ({ productId: it.product.id, quantity: it.quantity })),
};
if (restorePayload.items.length > 0) {
await this.inventoryService.restoreToInventory(em, restorePayload);
}
}
em.persist(payment);
if (payment.order) em.persist(payment.order);
await em.flush();
this.logger.log(
`Marked payment ${payment.id} and order ${payment.order?.id} as failed and restored inventory`,
);
});
} catch (err) {
this.logger.error(`Error processing payment ${p.id}: ${err.message}`, err.stack);
}
}
} catch (err) {
this.logger.error(`OrdersCrone failed: ${err.message}`, err.stack);
}
}
// run every 15 minutes to complete orders that have been in shipped/delivered statuses for more than 3 hours
@Cron('*/15 * * * *', {
name: 'completeOldDeliveredOrders',
timeZone: 'UTC',
})
async completeOldDeliveredOrders() {
try {
const cutoff = new Date(Date.now() - 3 * 60 * 60 * 1000); // 3 hours ago
this.logger.debug('Searching for orders in shipped/delivered statuses older than 3 hours');
const orders = await this.em.find(
Order,
{
status: {
$in: [
OrderStatus.SHIPPED,
OrderStatus.DELIVERED_TO_WAITER,
OrderStatus.DELIVERED_TO_RECEPTIONIST,
],
},
updatedAt: { $lte: cutoff },
},
{ populate: ['restaurant'] },
);
if (!orders || orders.length === 0) {
return;
}
this.logger.log(`Found ${orders.length} orders to mark as completed`);
for (const order of orders) {
try {
await this.em.transactional(async em => {
// reload inside transaction to avoid concurrency issues
const reloadedOrder = await em.findOne(
Order,
{ id: order.id },
{ populate: ['restaurant', 'user'] },
);
if (!reloadedOrder) return;
if (
![
OrderStatus.SHIPPED,
OrderStatus.DELIVERED_TO_WAITER,
OrderStatus.DELIVERED_TO_RECEPTIONIST,
].includes(reloadedOrder.status)
) {
return;
}
const previousStatus = reloadedOrder.status;
const =
typeof reloadedOrder.restaurant === 'string'
? reloadedOrder.restaurant
: reloadedOrder.restaurant.id;
// Update order status and history
reloadedOrder.status = OrderStatus.COMPLETED;
reloadedOrder.history.push({
status: OrderStatus.COMPLETED,
changedAt: new Date(),
desc: 'تکمیل سفارش توسط سیستم',
});
em.persist(reloadedOrder);
await em.flush();
// // Emit event after transaction completes
this.eventEmitter.emit(
OrderStatusChangedEvent.name,
new OrderStatusChangedEvent(
reloadedOrder.id,
reloadedOrder.user?.id || '',
String(reloadedOrder.orderNumber) || '',
,
previousStatus,
OrderStatus.COMPLETED,
'admin',
),
);
this.logger.log(`Marked order ${reloadedOrder.id} as completed`);
});
} catch (err) {
this.logger.error(`Error processing order ${order.id}: ${err.message}`, err.stack);
}
}
} catch (err) {
this.logger.error(`completeOldDeliveredOrders cron failed: ${err.message}`, err.stack);
}
}
} }
@@ -1,4 +1,4 @@
import { Entity, Enum, Index, ManyToOne, Property } from '@mikro-orm/core'; import { Entity, Enum, Index, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity'; import { BaseEntity } from '../../../common/entities/base.entity';
import { Order } from './order.entity'; import { Order } from './order.entity';
import { Product } from 'src/modules/product/entities/product.entity'; import { Product } from 'src/modules/product/entities/product.entity';
@@ -8,6 +8,9 @@ import { OrderItemStatus } from '../interface/order.interface';
@Index({ properties: ['order'] }) @Index({ properties: ['order'] })
@Index({ properties: ['product'] }) @Index({ properties: ['product'] })
export class OrderItem extends BaseEntity { export class OrderItem extends BaseEntity {
@PrimaryKey({ type: 'bigint', autoincrement: true })
id: bigint
@ManyToOne(() => Order) @ManyToOne(() => Order)
order!: Order; order!: Order;
@@ -7,17 +7,22 @@ import {
Collection, Collection,
Cascade, Cascade,
Enum, Enum,
PrimaryKey,
} from '@mikro-orm/core'; } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity'; import { BaseEntity } from '../../../common/entities/base.entity';
import { OrderStatus } from '../interface/order.interface'; import { OrderStatus } from '../interface/order.interface';
import { User } from '../../user/entities/user.entity'; import { User } from '../../user/entities/user.entity';
import { OrderItem } from './order-item.entity'; import { OrderItem } from './order-item.entity';
import { Payment } from 'src/modules/payment/entities/payment.entity'; import { Payment } from 'src/modules/payment/entities/payment.entity';
import { ulid } from 'ulid';
@Entity({ tableName: 'orders' }) @Entity({ tableName: 'orders' })
@Index({ properties: ['user', 'status'] }) @Index({ properties: ['user', 'status'] })
@Index({ properties: ['status'] }) @Index({ properties: ['status'] })
export class Order extends BaseEntity { export class Order extends BaseEntity {
@PrimaryKey({type:'string',columnType:'char(26)'})
id:string=ulid()
@ManyToOne(() => User) @ManyToOne(() => User)
user!: User; user!: User;
+170 -171
View File
@@ -10,8 +10,7 @@ import { OrderRepository } from '../repositories/order.repository';
import { OrderStatus } from '../interface/order.interface'; import { OrderStatus } from '../interface/order.interface';
import { PaymentMethodEnum } from 'src/modules/payment/interface/payment'; import { PaymentMethodEnum } from 'src/modules/payment/interface/payment';
import { UserService } from 'src/modules/user/providers/user.service'; import { UserService } from 'src/modules/user/providers/user.service';
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/user/interface/wallet';
@Injectable() @Injectable()
export class OrderListeners { export class OrderListeners {
private readonly logger = new Logger(OrderListeners.name); private readonly logger = new Logger(OrderListeners.name);
@@ -30,184 +29,184 @@ export class OrderListeners {
// this.orderCompletedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_ORDER_STATUS_COMPLETED') ?? '123'; // this.orderCompletedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_ORDER_STATUS_COMPLETED') ?? '123';
} }
private getStatusFarsi(status: OrderStatus): string { // private getStatusFarsi(status: OrderStatus): string {
const statusMap: Record<OrderStatus, string> = { // const statusMap: Record<OrderStatus, string> = {
[OrderStatus.PENDING_PAYMENT]: 'در انتظار پرداخت', // [OrderStatus.PENDING_PAYMENT]: 'در انتظار پرداخت',
[OrderStatus.PAID]: 'پرداخت شده', // [OrderStatus.PAID]: 'پرداخت شده',
[OrderStatus.PREPARING]: 'در حال آماده‌سازی', // [OrderStatus.PREPARING]: 'در حال آماده‌سازی',
[OrderStatus.DELIVERED_TO_RECEPTIONIST]: 'تحویل به پذیرش', // [OrderStatus.DELIVERED_TO_RECEPTIONIST]: 'تحویل به پذیرش',
[OrderStatus.DELIVERED_TO_WAITER]: 'تحویل به گارسون', // [OrderStatus.DELIVERED_TO_WAITER]: 'تحویل به گارسون',
[OrderStatus.SHIPPED]: 'ارسال شده', // [OrderStatus.SHIPPED]: 'ارسال شده',
[OrderStatus.COMPLETED]: 'تکمیل شده', // [OrderStatus.COMPLETED]: 'تکمیل شده',
[OrderStatus.CANCELED]: 'لغو شده', // [OrderStatus.CANCELED]: 'لغو شده',
}; // };
return statusMap[status] || status; // return statusMap[status] || status;
} // }
@OnEvent(OrderCreatedEvent.name) // @OnEvent(OrderCreatedEvent.name)
async handleOrderCreated(event: OrderCreatedEvent) { // async handleOrderCreated(event: OrderCreatedEvent) {
try { // try {
this.logger.log( // this.logger.log(
`Order created event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`, // `Order created event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
); // );
const order = await this.OrderRepository.findOne(event.orderId); // const order = await this.OrderRepository.findOne(event.orderId);
if (order?.paymentMethod.method === PaymentMethodEnum.Online) { // if (order?.paymentMethod.method === PaymentMethodEnum.Online) {
return; // return;
} // }
// get admnin os restuaraant that have order permissuins // // get admnin os restuaraant that have order permissuins
const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS); // const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS);
const recipients = admins.map(admin => ({ // const recipients = admins.map(admin => ({
adminId: admin.id, // adminId: admin.id,
})); // }));
await this.notificationService.sendNotification({ // await this.notificationService.sendNotification({
: event.,
message: { // message: {
title: NotifTitleEnum.ORDER_CREATED, // title: NotifTitleEnum.ORDER_CREATED,
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`, // content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
sms: { // sms: {
templateId: this.orderCreatedSmsTemplateId, // templateId: this.orderCreatedSmsTemplateId,
parameters: { // parameters: {
orderNumber: event.orderNumber, // orderNumber: event.orderNumber,
total: event.total.toString(), // total: event.total.toString(),
}, // },
}, // },
pushNotif: { // pushNotif: {
title: `سفارش جدید`, // title: `سفارش جدید`,
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`, // content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
icon: `/`, // icon: `/`,
action: { // action: {
type: NotifTitleEnum.ORDER_CREATED, // type: NotifTitleEnum.ORDER_CREATED,
url: `/`, // url: `/`,
}, // },
}, // },
}, // },
recipients, // recipients,
metadata: { // metadata: {
priority: 1, // priority: 1,
}, // },
}); // });
} catch (error) { // } catch (error) {
this.logger.error( // this.logger.error(
`Failed to send notification for order created event: ${event.}`, // `Failed to send notification for order created event: ${event.}`,
error instanceof Error ? error.stack : String(error), // error instanceof Error ? error.stack : String(error),
); // );
} // }
} // }
@OnEvent(OrderStatusChangedEvent.name) // @OnEvent(OrderStatusChangedEvent.name)
async handleOrderStatusChanged(event: OrderStatusChangedEvent) { // async handleOrderStatusChanged(event: OrderStatusChangedEvent) {
try { // try {
this.logger.log( // this.logger.log(
`Order status changed event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`, // `Order status changed event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
); // );
//TODO : REFACTOR to use queue or other way to handle this // //TODO : REFACTOR to use queue or other way to handle this
const recipients = [ // const recipients = [
{ // {
userId: event.userId, // userId: event.userId,
}, // },
]; // ];
if (event.newStatus === OrderStatus.COMPLETED) { // if (event.newStatus === OrderStatus.COMPLETED) {
if (!event?.userId) { // if (!event?.userId) {
this.logger.log( // this.logger.log(
`User not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`, // `User not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
); // );
} // }
// const restaurant = await this.RestaurantRepository.findOne(event.); // // const restaurant = await this.RestaurantRepository.findOne(event.);
// if (!restaurant) { // // if (!restaurant) {
// this.logger.log( // // this.logger.log(
// `Restaurant not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`, // // `Restaurant not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
// ); // // );
// return; // // return;
// } // // }
// const score = restaurant.score; // // const score = restaurant.score;
// if (!score) { // // if (!score) {
// this.logger.log( // // this.logger.log(
// `Score not found for restaurant: ${event.} and order number: ${event.orderNumber}`, // // `Score not found for restaurant: ${event.} and order number: ${event.orderNumber}`,
// ); // // );
// return; // // return;
// } // // }
// increase score for user // // increase score for user
// const order = await this.OrderRepository.findOne(event.orderId); // // const order = await this.OrderRepository.findOne(event.orderId);
// if (!order) { // // if (!order) {
// this.logger.log( // // this.logger.log(
// `Order not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`, // // `Order not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
// ); // // );
// return; // // return;
// } // // }
// this.userService.createWalletTransaction(event.userId, event., { // // this.userService.createWalletTransaction(event.userId, event., {
// amount: order.subTotal, // // amount: order.subTotal,
// type: WalletTransactionType.CREDIT, // // type: WalletTransactionType.CREDIT,
// reason: WalletTransactionReason.ORDER_COMPLETED_DEPOSIT, // // reason: WalletTransactionReason.ORDER_COMPLETED_DEPOSIT,
// }); // // });
await this.notificationService.sendNotification({ // await this.notificationService.sendNotification({
: event., // : event.,
message: { // message: {
title: NotifTitleEnum.ORDER_STATUS_CHANGED, // title: NotifTitleEnum.ORDER_STATUS_CHANGED,
content: `لطفابرای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`, // content: `لطفابرای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`,
sms: { // sms: {
templateId: this.orderCreatedSmsTemplateId, // templateId: this.orderCreatedSmsTemplateId,
parameters: { // parameters: {
orderNumber: event.orderNumber, // orderNumber: event.orderNumber,
}, // },
}, // },
pushNotif: { // pushNotif: {
title: `تغییر وضعیت سفارش`, // title: `تغییر وضعیت سفارش`,
content: `لطفا برای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`, // content: `لطفا برای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`,
icon: `/`, // icon: `/`,
action: { // action: {
type: NotifTitleEnum.ORDER_STATUS_CHANGED, // type: NotifTitleEnum.ORDER_STATUS_CHANGED,
url: ``, // url: ``,
}, // },
}, // },
}, // },
recipients, // recipients,
metadata: { // metadata: {
priority: 1, // priority: 1,
}, // },
}); // });
} else { // } else {
await this.notificationService.sendNotification({ // await this.notificationService.sendNotification({
: event., // : event.,
message: { // message: {
title: NotifTitleEnum.ORDER_STATUS_CHANGED, // title: NotifTitleEnum.ORDER_STATUS_CHANGED,
content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`, // content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`,
sms: { // sms: {
templateId: this.orderStatusChangedSmsTemplateId, // templateId: this.orderStatusChangedSmsTemplateId,
parameters: { // parameters: {
orderNumber: event.orderNumber, // orderNumber: event.orderNumber,
status: this.getStatusFarsi(event.newStatus), // status: this.getStatusFarsi(event.newStatus),
}, // },
}, // },
pushNotif: { // pushNotif: {
title: `تغییر وضعیت سفارش`, // title: `تغییر وضعیت سفارش`,
content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`, // content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`,
icon: `/`, // icon: `/`,
action: { // action: {
type: NotifTitleEnum.ORDER_STATUS_CHANGED, // type: NotifTitleEnum.ORDER_STATUS_CHANGED,
url: ``, // url: ``,
}, // },
}, // },
}, // },
recipients, // recipients,
metadata: { // metadata: {
priority: 1, // priority: 1,
}, // },
}); // });
} // }
} catch (error) { // } catch (error) {
this.logger.error( // this.logger.error(
`Failed to send notification for order status changed event: ${event.}`, // `Failed to send notification for order status changed event: ${event.}`,
error instanceof Error ? error.stack : String(error), // error instanceof Error ? error.stack : String(error),
); // );
} // }
} // }
} }
@@ -5,7 +5,7 @@ import { Order } from '../entities/order.entity';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
import { OrderStatus } from '../interface/order.interface'; import { OrderStatus } from '../interface/order.interface';
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payment/interface/payment'; import { PaymentMethodEnum, PaymentStatusEnum } from '../../payment/interface/payment';
import { Review } from '../../review/entities/review.entity';
type FindOrdersOpts = { type FindOrdersOpts = {
page?: number; page?: number;
@@ -31,7 +31,7 @@ export class OrderRepository extends EntityRepository<Order> {
* Find orders with pagination and optional filters. * Find orders with pagination and optional filters.
* Supports: statuses, paymentStatus, search (orderNumber), date range, ordering. * Supports: statuses, paymentStatus, search (orderNumber), date range, ordering.
*/ */
async findAllPaginated(: string, opts: FindOrdersOpts = {}): Promise<PaginatedResult<Order>> { async findAllPaginated( opts: FindOrdersOpts = {}): Promise<PaginatedResult<Order>> {
const { const {
page = 1, page = 1,
limit = 10, limit = 10,
@@ -47,7 +47,7 @@ export class OrderRepository extends EntityRepository<Order> {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const where: FilterQuery<Order> = { restaurant: { id: } }; const where: FilterQuery<Order> = { };
// Filter by statuses // Filter by statuses
if (statuses) { if (statuses) {
@@ -99,19 +99,7 @@ export class OrderRepository extends EntityRepository<Order> {
where.$or = searchConditions; where.$or = searchConditions;
} }
// Filter: Exclude orders with payment method Online and status pending_payment
if (excludeOnlinePendingPayment) {
const existingConditions = where.$and || [];
where.$and = [
...existingConditions,
{
$or: [
{ paymentMethod: { method: { $ne: PaymentMethodEnum.Online } } },
{ status: { $ne: OrderStatus.PENDING_PAYMENT } },
],
},
];
}
// First, fetch orders without reviews // First, fetch orders without reviews
const [data, total] = await this.findAndCount(where, { const [data, total] = await this.findAndCount(where, {
@@ -122,7 +110,7 @@ export class OrderRepository extends EntityRepository<Order> {
}); });
// Collect all (orderId, productId) pairs for efficient review lookup // Collect all (orderId, productId) pairs for efficient review lookup
const orderproductPairs: Array<{ orderId: string; productId: string }> = []; const orderproductPairs: Array<{ orderId: string; productId: bigint }> = [];
for (const order of data) { for (const order of data) {
for (const item of order.items.getItems()) { for (const item of order.items.getItems()) {
if (item.product?.id) { if (item.product?.id) {
@@ -131,30 +119,7 @@ export class OrderRepository extends EntityRepository<Order> {
} }
} }
// Fetch all relevant reviews in a single query
const reviewsMap: Map<string, string> = new Map();
if (orderproductPairs.length > 0) {
const orderIds = [...new Set(orderproductPairs.map(p => p.orderId))];
const productIds = [...new Set(orderproductPairs.map(p => p.productId))];
const reviews = await this.em.find(
Review,
{
order: { id: { $in: orderIds } },
product: { id: { $in: productIds } },
},
{
fields: ['id', 'order', 'product'],
populate: ['order', 'product'],
},
);
// Create a map: key = `${orderId}-${productId}`, value = reviewId
for (const review of reviews) {
const key = `${review.order.id}-${review.product.id}`;
reviewsMap.set(key, review.id);
}
}
// Map reviewIds to products // Map reviewIds to products
for (const order of data) { for (const order of data) {
@@ -164,7 +129,7 @@ export class OrderRepository extends EntityRepository<Order> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const product = item.product as any; const product = item.product as any;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
product.reviewId = reviewsMap.get(key) || null;
} }
} }
} }
@@ -15,9 +15,9 @@ import { CreatePaymentMethodDto } from '../dto/create-payment-method.dto';
import { UpdatePaymentMethodDto } from '../dto/update-payment-method.dto'; import { UpdatePaymentMethodDto } from '../dto/update-payment-method.dto';
import { VerifyPaymentDto } from '../dto/verify-payment.dto'; import { VerifyPaymentDto } from '../dto/verify-payment.dto';
import { PaymentChartDto } from '../dto/payment-chart.dto'; import { PaymentChartDto } from '../dto/payment-chart.dto';
import { , UserId } from 'src/common/decorators'; import { UserId } from 'src/common/decorators';
import { AuthGuard } from 'src/modules/auth/guards/auth.guard'; import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
import { API_HEADER_SLUG } from 'src/common/constants';
import { Permissions } from 'src/common/decorators/permissions.decorator'; import { Permissions } from 'src/common/decorators/permissions.decorator';
import { Permission } from 'src/common/enums/permission.enum'; import { Permission } from 'src/common/enums/permission.enum';
@@ -29,24 +29,9 @@ export class PaymentsController {
private readonly paymentMethodService: PaymentMethodService, private readonly paymentMethodService: PaymentMethodService,
) { } ) { }
@UseGuards(AuthGuard)
@ApiBearerAuth()
@Get('public/payments/methods/restaurant')
@ApiOperation({ summary: 'Get the restaurant payment methods' })
@ApiNotFoundResponse({ description: 'Restaurant payment methods not found' })
getTheRestaurantPaymentMethods() {
return this.paymentMethodService.findByRestaurant();
}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@Get('public/payments')
@ApiOperation({ summary: 'Get all the restaurant payments for user' })
findAllByRestaurant(@UserId() userId: string,) {
return this.paymentsService.findAllPaymentsBy(, userId);
}
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@@ -67,77 +52,67 @@ export class PaymentsController {
return this.paymentsService.verifyOnlinePayment(verifyPaymentDto.authority, verifyPaymentDto.orderId); return this.paymentsService.verifyOnlinePayment(verifyPaymentDto.authority, verifyPaymentDto.orderId);
} }
/** admin routes */
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_PAYMENTS)
@ApiBearerAuth()
@Get('admin/payments/methods')
@ApiOperation({ summary: 'Get restaurant all payment methods' })
findByRestaurant() {
return this.paymentMethodService.findByRestaurant();
}
@UseGuards(AdminAuthGuard) // @UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_PAYMENTS) // @Permissions(Permission.MANAGE_PAYMENTS)
@ApiBearerAuth() // @ApiBearerAuth()
@Post('admin/payments/methods') // @Post('admin/payments/methods')
@ApiOperation({ summary: 'Create a new restaurant payment method' }) // @ApiOperation({ summary: 'Create a new restaurant payment method' })
@ApiBody({ type: CreatePaymentMethodDto }) // @ApiBody({ type: CreatePaymentMethodDto })
createRestaurantPaymentMethod(, @Body() createPaymentMethodDto: CreatePaymentMethodDto) { // createRestaurantPaymentMethod( @Body() createPaymentMethodDto: CreatePaymentMethodDto) {
return this.paymentMethodService.create(, createPaymentMethodDto); // return this.paymentMethodService.create( createPaymentMethodDto);
} // }
@UseGuards(AdminAuthGuard) // @UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_PAYMENTS) // @Permissions(Permission.MANAGE_PAYMENTS)
@ApiBearerAuth() // @ApiBearerAuth()
@Get('admin/payments/methods/:paymentMethodId') // @Get('admin/payments/methods/:paymentMethodId')
@ApiOperation({ summary: 'Get restaurant payment method by ID' }) // @ApiOperation({ summary: 'Get restaurant payment method by ID' })
@ApiParam({ name: 'paymentMethodId', type: 'string', description: 'Payment method ID' }) // @ApiParam({ name: 'paymentMethodId', type: 'string', description: 'Payment method ID' })
findOneRestaurantPaymentMethod(@Param('paymentMethodId') paymentMethodId: string) { // findOneRestaurantPaymentMethod(@Param('paymentMethodId') paymentMethodId: string) {
return this.paymentMethodService.findOne(paymentMethodId); // return this.paymentMethodService.findOne(paymentMethodId);
} // }
@UseGuards(AdminAuthGuard) // @UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_PAYMENTS) // @Permissions(Permission.MANAGE_PAYMENTS)
@ApiBearerAuth() // @ApiBearerAuth()
@Patch('admin/payments/methods/:paymentMethodId') // @Patch('admin/payments/methods/:paymentMethodId')
@ApiOperation({ summary: 'Update a restaurant payment method' }) // @ApiOperation({ summary: 'Update a restaurant payment method' })
@ApiParam({ name: 'paymentMethodId', type: 'string', description: 'Payment method ID' }) // @ApiParam({ name: 'paymentMethodId', type: 'string', description: 'Payment method ID' })
@ApiBody({ type: UpdatePaymentMethodDto }) // @ApiBody({ type: UpdatePaymentMethodDto })
updateRestaurantPaymentMethod( // updateRestaurantPaymentMethod(
@Param('paymentMethodId') paymentMethodId: string, // @Param('paymentMethodId') paymentMethodId: string,
@Body() updatePaymentMethodDto: UpdatePaymentMethodDto, // @Body() updatePaymentMethodDto: UpdatePaymentMethodDto,
) { // ) {
return this.paymentMethodService.update(paymentMethodId, updatePaymentMethodDto); // return this.paymentMethodService.update(paymentMethodId, updatePaymentMethodDto);
} // }
@UseGuards(AdminAuthGuard) // @UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_PAYMENTS) // @Permissions(Permission.MANAGE_PAYMENTS)
@ApiBearerAuth() // @ApiBearerAuth()
@Delete('admin/payments/methods/:paymentMethodId') // @Delete('admin/payments/methods/:paymentMethodId')
@ApiOperation({ summary: 'Delete a restaurant payment method' }) // @ApiOperation({ summary: 'Delete a restaurant payment method' })
@ApiParam({ name: 'paymentMethodId', type: 'string', description: 'Payment method ID' }) // @ApiParam({ name: 'paymentMethodId', type: 'string', description: 'Payment method ID' })
removeRestaurantPaymentMethod(@Param('paymentMethodId') paymentMethodId: string) { // removeRestaurantPaymentMethod(@Param('paymentMethodId') paymentMethodId: string) {
return this.paymentMethodService.remove(paymentMethodId); // return this.paymentMethodService.remove(paymentMethodId);
} // }
@UseGuards(AdminAuthGuard) // @UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_PAYMENTS) // @Permissions(Permission.MANAGE_PAYMENTS)
@ApiBearerAuth() // @ApiBearerAuth()
@Post('admin/payments/verify-cash-method/:paymentId') // @Post('admin/payments/verify-cash-method/:paymentId')
@ApiOperation({ summary: 'Verify cash payment ' }) // @ApiOperation({ summary: 'Verify cash payment ' })
@ApiParam({ name: 'paymentId', type: 'string', description: 'Payment ID' }) // @ApiParam({ name: 'paymentId', type: 'string', description: 'Payment ID' })
verifyCashPayment(@Param('paymentId') paymentId: string) { // verifyCashPayment(@Param('paymentId') paymentId: string) {
return this.paymentsService.verifyCashPayment(paymentId); // return this.paymentsService.verifyCashPayment(paymentId);
} // }
// @UseGuards(AdminAuthGuard)
// @Permissions(Permission.VIEW_REPORTS)
// @ApiBearerAuth()
// @Get('admin/payments/chart')
// @ApiOperation({ summary: 'Get payment chart data with date and period filters' })
@UseGuards(AdminAuthGuard)
@Permissions(Permission.VIEW_REPORTS)
@ApiBearerAuth()
@Get('admin/payments/chart')
@ApiOperation({ summary: 'Get payment chart data with date and period filters' })
getPaymentChart(@Query() query: PaymentChartDto,) {
return this.paymentsService.getChartData(query,);
}
} }
@@ -1,25 +0,0 @@
import { Entity, Enum, Index, ManyToOne, Property, Unique } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { PaymentGatewayEnum, PaymentMethodEnum } from '../interface/payment';
@Entity({ tableName: 'payment_methods' })
export class PaymentMethod extends BaseEntity {
@Enum(() => PaymentMethodEnum)
method!: PaymentMethodEnum;
@Enum(() => PaymentGatewayEnum)
gateway: PaymentGatewayEnum | null = null;
@Property({ nullable: true })
description?: string;
@Property({ default: true })
enabled: boolean = true;
@Property({ type: 'integer', default: 0 })
order: number = 0;
@Property({ nullable: true })
merchantId?: string;
}
+130 -130
View File
@@ -32,139 +32,139 @@ export class PaymentListeners {
return methodMap[method] || method; return methodMap[method] || method;
} }
@OnEvent(paymentSucceedEvent.name) // @OnEvent(paymentSucceedEvent.name)
async handlePaymentSucceed(event: paymentSucceedEvent) { // async handlePaymentSucceed(event: paymentSucceedEvent) {
try { // try {
this.logger.log( // this.logger.log(
`Payment paid event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`, // `Payment paid event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
); // );
// get admnin os restuaraant that have order permissuins // // get admnin os restuaraant that have order permissuins
const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS); // const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS);
// const order=await // // const order=await
const recipients = admins.map(admin => ({ // const recipients = admins.map(admin => ({
adminId: admin.id, // adminId: admin.id,
})); // }));
await this.notificationService.sendNotification({ // await this.notificationService.sendNotification({
: event., // : event.,
message: { // message: {
title: NotifTitleEnum.PAYMENT_SUCCESS, // title: NotifTitleEnum.PAYMENT_SUCCESS,
content: `پرداخت سفارش شماره ${event.orderNumber} با روش ${this.getPaymentMethodFarsi(event.paymentMethod)} و مبلغ ${event.total} تومان با موفقیت انجام شد`, // content: `پرداخت سفارش شماره ${event.orderNumber} با روش ${this.getPaymentMethodFarsi(event.paymentMethod)} و مبلغ ${event.total} تومان با موفقیت انجام شد`,
sms: { // sms: {
templateId: this.orderCreatedSmsTemplateId, // templateId: this.orderCreatedSmsTemplateId,
parameters: { // parameters: {
orderNumber: event.orderNumber, // orderNumber: event.orderNumber,
}, // },
}, // },
pushNotif: { // pushNotif: {
title: `پرداخت موفق`, // title: `پرداخت موفق`,
content: `پرداخت سفارش شماره ${event.orderNumber} با روش ${this.getPaymentMethodFarsi(event.paymentMethod)} و مبلغ ${event.total} تومان با موفقیت انجام شد`, // content: `پرداخت سفارش شماره ${event.orderNumber} با روش ${this.getPaymentMethodFarsi(event.paymentMethod)} و مبلغ ${event.total} تومان با موفقیت انجام شد`,
icon: ``, // icon: ``,
action: { // action: {
type: NotifTitleEnum.PAYMENT_SUCCESS, // type: NotifTitleEnum.PAYMENT_SUCCESS,
url: `/`, // url: `/`,
}, // },
}, // },
}, // },
recipients, // recipients,
metadata: { // metadata: {
priority: 1, // priority: 1,
}, // },
}); // });
} catch (error) { // } catch (error) {
this.logger.error( // this.logger.error(
`Failed to send notification for order created event: ${event.}`, // `Failed to send notification for order created event: ${event.}`,
error instanceof Error ? error.stack : String(error), // error instanceof Error ? error.stack : String(error),
); // );
} // }
} // }
@OnEvent(onlinePaymentSucceedEvent.name) // @OnEvent(onlinePaymentSucceedEvent.name)
async handleOnlinePaymentSucceed(event: onlinePaymentSucceedEvent) { // async handleOnlinePaymentSucceed(event: onlinePaymentSucceedEvent) {
try { // try {
this.logger.log( // this.logger.log(
`Online payment succeed event received: ${event.paymentId} for restaurant: ${event.} and order number: ${event.orderNumber}`, // `Online payment succeed event received: ${event.paymentId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
); // );
const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS); // const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS);
const recipients = admins.map(admin => ({ // const recipients = admins.map(admin => ({
adminId: admin.id, // adminId: admin.id,
})); // }));
// admin notifs // // admin notifs
await this.notificationService.sendNotification({ // await this.notificationService.sendNotification({
: event., // : event.,
message: { // message: {
title: NotifTitleEnum.ORDER_CREATED, // title: NotifTitleEnum.ORDER_CREATED,
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`, // content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
sms: { // sms: {
templateId: this.orderCreatedSmsTemplateId, // templateId: this.orderCreatedSmsTemplateId,
parameters: { // parameters: {
orderNumber: event.orderNumber, // orderNumber: event.orderNumber,
total: event.total.toString(), // total: event.total.toString(),
}, // },
}, // },
pushNotif: { // pushNotif: {
title: `سفارش جدید`, // title: `سفارش جدید`,
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`, // content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
icon: `/`, // icon: `/`,
action: { // action: {
type: NotifTitleEnum.ORDER_CREATED, // type: NotifTitleEnum.ORDER_CREATED,
url: `/`, // url: `/`,
}, // },
}, // },
}, // },
recipients, // recipients,
metadata: { // metadata: {
priority: 1, // priority: 1,
}, // },
}); // });
const order = await this.orderService.findOne(event.orderId, event.); // const order = await this.orderService.findOne(event.orderId, event.);
if (!order) { // if (!order) {
this.logger.error( // this.logger.error(
`Order not found: ${event.orderId} for restaurant: ${event.}`, // `Order not found: ${event.orderId} for restaurant: ${event.}`,
); // );
return; // return;
} // }
const userRecipients = [ // const userRecipients = [
{ // {
userId: order.user.id, // userId: order.user.id,
}, // },
]; // ];
// user notif // // user notif
await this.notificationService.sendNotification({ // await this.notificationService.sendNotification({
: event., // : event.,
message: { // message: {
title: NotifTitleEnum.PAYMENT_SUCCESS, // title: NotifTitleEnum.PAYMENT_SUCCESS,
content: `پرداخت سفارش شماره ${order.orderNumber} با روش ${this.getPaymentMethodFarsi(order.paymentMethod.method)} و مبلغ ${order.total} تومان با موفقیت انجام شد`, // content: `پرداخت سفارش شماره ${order.orderNumber} با روش ${this.getPaymentMethodFarsi(order.paymentMethod.method)} و مبلغ ${order.total} تومان با موفقیت انجام شد`,
sms: { // sms: {
templateId: this.paymentSucceedSmsTemplateId, // templateId: this.paymentSucceedSmsTemplateId,
parameters: { // parameters: {
orderNumber: event.orderNumber, // orderNumber: event.orderNumber,
total: event.total.toString(), // total: event.total.toString(),
}, // },
}, // },
pushNotif: { // pushNotif: {
title: `پرداخت موفق`, // title: `پرداخت موفق`,
content: `پرداخت سفارش شماره ${order.orderNumber} با روش ${this.getPaymentMethodFarsi(order.paymentMethod.method)} و مبلغ ${order.total} تومان با موفقیت انجام شد`, // content: `پرداخت سفارش شماره ${order.orderNumber} با روش ${this.getPaymentMethodFarsi(order.paymentMethod.method)} و مبلغ ${order.total} تومان با موفقیت انجام شد`,
icon: `/`, // icon: `/`,
action: { // action: {
type: NotifTitleEnum.PAYMENT_SUCCESS, // type: NotifTitleEnum.PAYMENT_SUCCESS,
url: `/`, // url: `/`,
}, // },
}, // },
}, // },
recipients: userRecipients, // recipients: userRecipients,
metadata: { // metadata: {
priority: 1, // priority: 1,
}, // },
}); // });
} catch (error) { // } catch (error) {
this.logger.error( // this.logger.error(
`Failed to send notification for online payment succeed event: ${event.}`, // `Failed to send notification for online payment succeed event: ${event.}`,
error instanceof Error ? error.stack : String(error), // error instanceof Error ? error.stack : String(error),
); // );
} // }
} // }
} }
@@ -1,10 +0,0 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { PaymentMethod } from '../entities/payment-method.entity';
@Injectable()
export class PaymentMethodRepository extends EntityRepository<PaymentMethod> {
constructor(readonly em: EntityManager) {
super(em, PaymentMethod);
}
}
@@ -1,63 +0,0 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql';
import { PaymentMethod } from '../entities/payment-method.entity';
import { PaymentMethodRepository } from '../repositories/payment-method.repository';
import { CreatePaymentMethodDto } from '../dto/create-payment-method.dto';
import { UpdatePaymentMethodDto } from '../dto/update-payment-method.dto';
import { RequiredEntityData } from '@mikro-orm/core';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { RestMessage, PaymentMessage } from 'src/common/enums/message.enum';
@Injectable()
export class PaymentMethodService {
constructor(
private readonly paymentMethodRepository: PaymentMethodRepository,
private readonly em: EntityManager,
) { }
async create(: string, createPaymentMethodDto: CreatePaymentMethodDto): Promise<PaymentMethod> {
// Check if restaurant exists
const restaurant = await this.em.findOne(Restaurant, { id: });
if (!restaurant) {
throw new NotFoundException(RestMessage.NOT_FOUND);
}
const paymentMethod = this.paymentMethodRepository.create({
restaurant,
...createPaymentMethodDto,
} as unknown as RequiredEntityData<PaymentMethod>);
await this.em.persistAndFlush(paymentMethod);
return paymentMethod;
}
async findAll(): Promise<PaymentMethod[]> {
return this.paymentMethodRepository.findAll({ populate: ['restaurant'] });
}
async findOne(id: string): Promise<PaymentMethod> {
const paymentMethod = await this.paymentMethodRepository.findOne({ id }, { populate: ['restaurant'] });
if (!paymentMethod) {
throw new NotFoundException(PaymentMessage.PAYMENT_METHOD_NOT_FOUND);
}
return paymentMethod;
}
async findByRestaurant(: string): Promise<PaymentMethod[]> {
return this.paymentMethodRepository.find({ restaurant: { id: } }, { populate: ['restaurant'] });
}
async update(id: string, updatePaymentMethodDto: UpdatePaymentMethodDto): Promise<PaymentMethod> {
const paymentMethod = await this.findOne(id);
this.em.assign(paymentMethod, updatePaymentMethodDto);
await this.em.flush();
return paymentMethod;
}
async remove(id: string): Promise<PaymentMethod> {
const paymentMethod = await this.findOne(id);
await this.em.removeAndFlush(paymentMethod);
return paymentMethod;
}
}
+297 -419
View File
@@ -5,15 +5,13 @@ import { EntityManager } from '@mikro-orm/postgresql';
import { Order } from '../../order/entities/order.entity'; import { Order } from '../../order/entities/order.entity';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { GatewayManager } from '../gateways/gateway.manager'; import { GatewayManager } from '../gateways/gateway.manager';
import { WalletTransaction } from 'src/modules/user/entities/wallet-transaction.entity'; import { OrderPaymentContext } from '../interface/payment';
import { OrderPaymentContext } from '../interface/payment';
import { OrderStatus } from 'src/modules/order/interface/order.interface'; import { OrderStatus } from 'src/modules/order/interface/order.interface';
import { ChartPeriodEnum, PaymentChartDto } from '../dto/payment-chart.dto'; import { ChartPeriodEnum, PaymentChartDto } from '../dto/payment-chart.dto';
import { PaymentMessage, OrderMessage } from 'src/common/enums/message.enum'; import { PaymentMessage, OrderMessage } from 'src/common/enums/message.enum';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { onlinePaymentSucceedEvent, paymentSucceedEvent } from '../events/payment.events'; import { onlinePaymentSucceedEvent, paymentSucceedEvent } from '../events/payment.events';
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/user/interface/wallet';
@Injectable() @Injectable()
export class PaymentsService { export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name); private readonly logger = new Logger(PaymentsService.name);
@@ -24,419 +22,299 @@ export class PaymentsService {
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
) { } ) { }
async payOrder(orderId: string): Promise<{ paymentUrl: string | null }> { // async payOrder(orderId: string): Promise<{ paymentUrl: string | null }> {
const ctx = await this.loadAndValidateOrder(orderId); // const ctx = await this.loadAndValidateOrder(orderId);
// Idempotency: avoid creating/charging again for already-paid orders // // Idempotency: avoid creating/charging again for already-paid orders
if (ctx.order.status === OrderStatus.PAID) { // if (ctx.order.status === OrderStatus.PAID) {
return { paymentUrl: null }; // return { paymentUrl: null };
} // }
switch (ctx.method) { // switch (ctx.method) {
case PaymentMethodEnum.Cash: // case PaymentMethodEnum.Cash:
await this.handleCashPayment(ctx); // await this.handleCashPayment(ctx);
return { paymentUrl: null }; // return { paymentUrl: null };
case PaymentMethodEnum.Wallet: // case PaymentMethodEnum.Wallet:
await this.handleWalletPayment(ctx); // await this.handleWalletPayment(ctx);
return { paymentUrl: null }; // return { paymentUrl: null };
case PaymentMethodEnum.Online: // case PaymentMethodEnum.Online:
return this.handleOnlinePayment(ctx); // return this.handleOnlinePayment(ctx);
default: // default:
throw new BadRequestException(PaymentMessage.UNSUPPORTED_PAYMENT_METHOD); // throw new BadRequestException(PaymentMessage.UNSUPPORTED_PAYMENT_METHOD);
} // }
} // }
private async loadAndValidateOrder(orderId: string): Promise<OrderPaymentContext> { // private async loadAndValidateOrder(orderId: string): Promise<OrderPaymentContext> {
const order = await this.em.findOne( // const order = await this.em.findOne(
Order, // Order,
{ id: orderId }, // { id: orderId },
{ populate: ['user', 'restaurant', 'paymentMethod', 'paymentMethod.restaurant'] }, // { populate: ['user', 'restaurant', 'paymentMethod', 'paymentMethod.restaurant'] },
); // );
if (!order) { // if (!order) {
throw new NotFoundException(OrderMessage.NOT_FOUND); // throw new NotFoundException(OrderMessage.NOT_FOUND);
} // }
if (order.total <= 0) { // if (order.total <= 0) {
throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO); // throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO);
} // }
const pm = order.paymentMethod; // const pm = order.paymentMethod;
if (!pm) { // if (!pm) {
throw new NotFoundException(PaymentMessage.PAYMENT_METHOD_NOT_FOUND); // throw new NotFoundException(PaymentMessage.PAYMENT_METHOD_NOT_FOUND);
} // }
if (pm.method === PaymentMethodEnum.Online) { // if (pm.method === PaymentMethodEnum.Online) {
if (!pm.gateway) throw new BadRequestException(PaymentMessage.GATEWAY_REQUIRED); // if (!pm.gateway) throw new BadRequestException(PaymentMessage.GATEWAY_REQUIRED);
if (!pm.merchantId) throw new BadRequestException(PaymentMessage.MERCHANT_ID_REQUIRED); // if (!pm.merchantId) throw new BadRequestException(PaymentMessage.MERCHANT_ID_REQUIRED);
if (!pm.restaurant?.domain) throw new BadRequestException(PaymentMessage.RESTAURANT_DOMAIN_REQUIRED); // if (!pm.restaurant?.domain) throw new BadRequestException(PaymentMessage.RESTAURANT_DOMAIN_REQUIRED);
} // }
return { // return {
order, // order,
amount: order.total, // amount: order.total,
method: pm.method, // method: pm.method,
gateway: pm.gateway ?? null, // gateway: pm.gateway ?? null,
merchantId: pm.merchantId ?? null, // merchantId: pm.merchantId ?? null,
restaurantDomain: pm.restaurant.domain ?? null, // restaurantDomain: pm.restaurant.domain ?? null,
}; // };
} // }
private async handleCashPayment(ctx: OrderPaymentContext): Promise<void> { // private async handleCashPayment(ctx: OrderPaymentContext): Promise<void> {
await this.getOrCreateLatestPendingPayment(ctx.order.id, { // await this.getOrCreateLatestPendingPayment(ctx.order.id, {
amount: ctx.amount, // amount: ctx.amount,
method: PaymentMethodEnum.Cash, // method: PaymentMethodEnum.Cash,
gateway: null, // gateway: null,
}); // });
} // }
// TODO clean this code and refactor it
private async handleWalletPayment(ctx: OrderPaymentContext): Promise<void> { // private async handleWalletPayment(ctx: OrderPaymentContext): Promise<void> {
await this.em.transactional(async em => { // await this.em.transactional(async em => {
const order = await em.findOne(Order, { id: ctx.order.id }, { populate: ['user', 'restaurant'] }); // const order = await em.findOne(Order, { id: ctx.order.id }, { populate: ['user', 'restaurant'] });
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND); // if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
if (order.status === OrderStatus.PAID) { // if (order.status === OrderStatus.PAID) {
return; // return;
} // }
const walletTransaction = await em.findOne(WalletTransaction, { // const walletTransaction = await em.findOne(WalletTransaction, {
user: { id: order.user.id }, // user: { id: order.user.id },
restaurant: { id: order.restaurant.id }, // restaurant: { id: order.restaurant.id },
}, { // }, {
orderBy: { createdAt: 'DESC' } // orderBy: { createdAt: 'DESC' }
}); // });
if (!walletTransaction) { // if (!walletTransaction) {
throw new NotFoundException('User wallet not found'); // throw new NotFoundException('User wallet not found');
} // }
if (walletTransaction.balance < ctx.amount) { // if (walletTransaction.balance < ctx.amount) {
throw new BadRequestException('Insufficient wallet balance'); // throw new BadRequestException('Insufficient wallet balance');
} // }
const newBalance = walletTransaction.balance - ctx.amount; // const newBalance = walletTransaction.balance - ctx.amount;
const payment = await this.getOrCreateLatestPendingPayment(order.id, { // const payment = await this.getOrCreateLatestPendingPayment(order.id, {
em, // em,
amount: ctx.amount, // amount: ctx.amount,
method: PaymentMethodEnum.Wallet, // method: PaymentMethodEnum.Wallet,
gateway: null, // gateway: null,
}); // });
const newWalletTransaction = em.create(WalletTransaction, { // const newWalletTransaction = em.create(WalletTransaction, {
user: order.user, // user: order.user,
restaurant: order.restaurant, // restaurant: order.restaurant,
amount: ctx.amount, // amount: ctx.amount,
type: WalletTransactionType.DEBIT, // type: WalletTransactionType.DEBIT,
reason: WalletTransactionReason.ORDER_PAYMENT, // reason: WalletTransactionReason.ORDER_PAYMENT,
balance: newBalance, // balance: newBalance,
}); // });
payment.status = PaymentStatusEnum.Paid; // payment.status = PaymentStatusEnum.Paid;
payment.paidAt = new Date(); // payment.paidAt = new Date();
order.status = OrderStatus.PAID; // order.status = OrderStatus.PAID;
em.persist([payment, order, newWalletTransaction]); // em.persist([payment, order, newWalletTransaction]);
await em.flush(); // await em.flush();
}); // });
this.eventEmitter.emit( // this.eventEmitter.emit(
paymentSucceedEvent.name, // paymentSucceedEvent.name,
new paymentSucceedEvent(ctx.order.id, ctx.order.restaurant.id, String(ctx.order?.orderNumber) || '', ctx.method, ctx.amount), // new paymentSucceedEvent(ctx.order.id, ctx.order.restaurant.id, String(ctx.order?.orderNumber) || '', ctx.method, ctx.amount),
); // );
} // }
private async handleOnlinePayment(ctx: OrderPaymentContext): Promise<{ paymentUrl: string }> { // private async handleOnlinePayment(ctx: OrderPaymentContext): Promise<{ paymentUrl: string }> {
const gateway = this.gatewayManager.get(ctx.gateway!); // const gateway = this.gatewayManager.get(ctx.gateway!);
const payment = await this.getOrCreateLatestPendingPayment(ctx.order.id, { // const payment = await this.getOrCreateLatestPendingPayment(ctx.order.id, {
amount: ctx.amount, // amount: ctx.amount,
method: PaymentMethodEnum.Online, // method: PaymentMethodEnum.Online,
gateway: ctx.gateway!, // gateway: ctx.gateway!,
}); // });
// If we already requested a gateway transaction, just return the same URL (idempotent) // // If we already requested a gateway transaction, just return the same URL (idempotent)
if (payment.transactionId) { // if (payment.transactionId) {
return { paymentUrl: gateway.getPaymentUrl(payment.transactionId) }; // return { paymentUrl: gateway.getPaymentUrl(payment.transactionId) };
} // }
const { transactionId } = await gateway.requestPayment({ // const { transactionId } = await gateway.requestPayment({
amount: ctx.amount, // amount: ctx.amount,
orderId: ctx.order.id, // orderId: ctx.order.id,
merchantId: ctx.merchantId!, // merchantId: ctx.merchantId!,
domain: ctx.restaurantDomain!, // domain: ctx.restaurantDomain!,
}); // });
payment.gateway = ctx.gateway; // payment.gateway = ctx.gateway;
payment.transactionId = transactionId; // payment.transactionId = transactionId;
payment.status = PaymentStatusEnum.Pending; // payment.status = PaymentStatusEnum.Pending;
await this.em.persistAndFlush(payment); // await this.em.persistAndFlush(payment);
return { // return {
paymentUrl: gateway.getPaymentUrl(transactionId), // paymentUrl: gateway.getPaymentUrl(transactionId),
}; // };
} // }
async verifyOnlinePayment(transactionId: string, orderId: string): Promise<Payment> { // async verifyOnlinePayment(transactionId: string, orderId: string): Promise<Payment> {
const payment = await this.em.transactional(async em => { // const payment = await this.em.transactional(async em => {
const payment = await em.findOne( // const payment = await em.findOne(
Payment, // Payment,
{ transactionId, order: { id: orderId } }, // { transactionId, order: { id: orderId } },
{ populate: ['order', 'order.paymentMethod'] }, // { populate: ['order', 'order.paymentMethod'] },
); // );
if (!payment) { // if (!payment) {
throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND); // throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND);
} // }
if (payment.status === PaymentStatusEnum.Paid) { // if (payment.status === PaymentStatusEnum.Paid) {
return payment; // return payment;
} // }
const pm = payment.order.paymentMethod; // const pm = payment.order.paymentMethod;
if (!pm?.merchantId || !payment.gateway) { // if (!pm?.merchantId || !payment.gateway) {
throw new BadRequestException(PaymentMessage.INVALID_PAYMENT_CONFIGURATION); // throw new BadRequestException(PaymentMessage.INVALID_PAYMENT_CONFIGURATION);
} // }
const gateway = this.gatewayManager.get(payment.gateway); // const gateway = this.gatewayManager.get(payment.gateway);
const result = await gateway.verifyPayment({ // const result = await gateway.verifyPayment({
merchantId: pm.merchantId, // merchantId: pm.merchantId,
amount: payment.amount, // amount: payment.amount,
transactionId: payment.transactionId!, // transactionId: payment.transactionId!,
}); // });
payment.verifyResponse = result.raw; // payment.verifyResponse = result.raw;
if (!result.success) { // if (!result.success) {
this.failPayment(payment); // this.failPayment(payment);
return payment; // return payment;
} // }
this.markPaid(payment, result.referenceId); // this.markPaid(payment, result.referenceId);
if (payment.order.status === OrderStatus.PENDING_PAYMENT) { // if (payment.order.status === OrderStatus.PENDING_PAYMENT) {
payment.order.status = OrderStatus.PAID; // payment.order.status = OrderStatus.PAID;
} // }
await em.flush(); // await em.flush();
return payment; // return payment;
}); // });
this.eventEmitter.emit( // this.eventEmitter.emit(
onlinePaymentSucceedEvent.name, // onlinePaymentSucceedEvent.name,
new onlinePaymentSucceedEvent(payment.id, payment.order.id, payment.order.restaurant.id, String(payment.order?.orderNumber) || '', payment.amount), // new onlinePaymentSucceedEvent(payment.id, payment.order.id, payment.order.restaurant.id, String(payment.order?.orderNumber) || '', payment.amount),
); // );
return payment; // return payment;
} // }
findAllPaymentsBy(: string, userId: string) { // findAllPaymentsBy(: string, userId: string) {
return this.em.find( // return this.em.find(
Payment, // Payment,
{ order: { restaurant: { id: }, user: { id: userId } } }, // { order: { restaurant: { id: }, user: { id: userId } } },
{ populate: ['order', 'order.paymentMethod'] }, // { populate: ['order', 'order.paymentMethod'] },
); // );
} // }
async verifyCashPayment(id: string): Promise<Payment> { // async verifyCashPayment(id: string): Promise<Payment> {
const payment = await this.em.transactional(async em => { // const payment = await this.em.transactional(async em => {
const payment = await em.findOne(Payment, id, { populate: ['order'] }); // const payment = await em.findOne(Payment, id, { populate: ['order'] });
if (!payment) { // if (!payment) {
throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND); // throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND);
} // }
if (payment.method !== PaymentMethodEnum.Cash) { // if (payment.method !== PaymentMethodEnum.Cash) {
throw new BadRequestException(PaymentMessage.PAYMENT_METHOD_NOT_CASH); // throw new BadRequestException(PaymentMessage.PAYMENT_METHOD_NOT_CASH);
} // }
if (payment.status === PaymentStatusEnum.Paid) { // if (payment.status === PaymentStatusEnum.Paid) {
throw new BadRequestException(PaymentMessage.PAYMENT_ALREADY_PAID); // throw new BadRequestException(PaymentMessage.PAYMENT_ALREADY_PAID);
} // }
if (payment.order.status === OrderStatus.PENDING_PAYMENT) { // if (payment.order.status === OrderStatus.PENDING_PAYMENT) {
payment.order.status = OrderStatus.PAID; // payment.order.status = OrderStatus.PAID;
} // }
payment.status = PaymentStatusEnum.Paid; // payment.status = PaymentStatusEnum.Paid;
payment.paidAt = new Date(); // payment.paidAt = new Date();
em.persist(payment); // em.persist(payment);
em.persist(payment.order); // em.persist(payment.order);
await em.flush(); // await em.flush();
return payment; // return payment;
}); // });
return payment; // return payment;
} // }
private markPaid(payment: Payment, referenceId: string) { // private markPaid(payment: Payment, referenceId: string) {
payment.status = PaymentStatusEnum.Paid; // payment.status = PaymentStatusEnum.Paid;
payment.paidAt = new Date(); // payment.paidAt = new Date();
payment.referenceId = referenceId; // payment.referenceId = referenceId;
} // }
private failPayment(payment: Payment) { // private failPayment(payment: Payment) {
payment.status = PaymentStatusEnum.Failed; // payment.status = PaymentStatusEnum.Failed;
payment.failedAt = new Date(); // payment.failedAt = new Date();
payment.order.status = OrderStatus.CANCELED; // payment.order.status = OrderStatus.CANCELED;
} // }
private async getOrCreateLatestPendingPayment( // private async getOrCreateLatestPendingPayment(
orderId: string, // orderId: string,
params: { // params: {
amount: number; // amount: number;
method: PaymentMethodEnum; // method: PaymentMethodEnum;
gateway: PaymentGatewayEnum | null; // gateway: PaymentGatewayEnum | null;
em?: EntityManager; // em?: EntityManager;
}, // },
): Promise<Payment> { // ): Promise<Payment> {
const em = params.em ?? this.em; // const em = params.em ?? this.em;
const existing = await em.findOne( // const existing = await em.findOne(
Payment, // Payment,
{ order: { id: orderId }, status: PaymentStatusEnum.Pending }, // { order: { id: orderId }, status: PaymentStatusEnum.Pending },
{ orderBy: { createdAt: 'DESC' } }, // { orderBy: { createdAt: 'DESC' } },
); // );
if (existing) { // if (existing) {
if (existing.method !== params.method) { // if (existing.method !== params.method) {
throw new BadRequestException(PaymentMessage.EXISTING_PENDING_PAYMENT_MISMATCH); // throw new BadRequestException(PaymentMessage.EXISTING_PENDING_PAYMENT_MISMATCH);
} // }
return existing; // return existing;
} // }
const orderRef = em.getReference(Order, orderId); // const orderRef = em.getReference(Order, orderId);
const payment = em.create(Payment, { // const payment = em.create(Payment, {
amount: params.amount, // amount: params.amount,
order: orderRef, // order: orderRef,
method: params.method, // method: params.method,
gateway: params.gateway, // gateway: params.gateway,
transactionId: null, // transactionId: null,
status: PaymentStatusEnum.Pending, // status: PaymentStatusEnum.Pending,
}); // });
em.persist(payment); // em.persist(payment);
await em.flush(); // await em.flush();
return payment; // return payment;
} // }
async getChartData(dto: PaymentChartDto, : string): Promise<Array<{ date: string; cash: number; online: number }>> {
const { startDate, endDate, type = ChartPeriodEnum.Daily } = dto;
const start = startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const end = endDate ? new Date(endDate) : new Date();
const startOfDay = new Date(start);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(end);
endOfDay.setHours(23, 59, 59, 999);
// 1. Map your Enum to valid Postgres date_trunc units
// 'Daily' is not valid in PG, it must be 'day'
const pgPeriod = {
[ChartPeriodEnum.Daily]: 'day',
[ChartPeriodEnum.Weekly]: 'week',
[ChartPeriodEnum.Monthly]: 'month',
}[type] || 'day';
const params: any[] = [startOfDay, endOfDay];
let restaurantFilter = '';
if () {
params.push();
restaurantFilter = `AND o.restaurant_id = ?`;
}
// MikroORM uses ? placeholders for parameter binding
// Ensure paid_at is not NULL and handle timestamp casting properly
const query = `
SELECT
TO_CHAR(DATE_TRUNC('${pgPeriod}', CAST(p.paid_at AS timestamp)), 'YYYY-MM-DD') as "date",
COALESCE(SUM(CASE WHEN p.method = '${PaymentMethodEnum.Cash}' THEN p.amount ELSE 0 END), 0)::numeric as cash,
COALESCE(SUM(CASE WHEN p.method = '${PaymentMethodEnum.Online}' THEN p.amount ELSE 0 END), 0)::numeric as online
FROM payments p
INNER JOIN orders o ON p.order_id = o.id
WHERE p.status = '${PaymentStatusEnum.Paid}'
AND p.paid_at IS NOT NULL
AND p.paid_at >= ?
AND p.paid_at <= ?
${restaurantFilter}
GROUP BY DATE_TRUNC('${pgPeriod}', CAST(p.paid_at AS timestamp))
ORDER BY DATE_TRUNC('${pgPeriod}', CAST(p.paid_at AS timestamp)) ASC
`;
this.logger.debug(`Chart query params: startOfDay=${startOfDay.toISOString()}, endOfDay=${endOfDay.toISOString()}, =${}`);
const result = await this.em.execute(query, params);
this.logger.debug(`Chart query returned ${result.length} rows`);
const dataMap = new Map<string, { cash: number; online: number }>();
result.forEach((row: any) => {
dataMap.set(row.date, {
cash: Number(row.cash),
online: Number(row.online),
});
});
const allDates = this.generateDateRange(startOfDay, endOfDay, type);
return allDates.map(date => ({
date,
cash: dataMap.get(date)?.cash ?? 0,
online: dataMap.get(date)?.online ?? 0,
}));
}
private generateDateRange(start: Date, end: Date, type: ChartPeriodEnum): string[] {
const dates: string[] = [];
const current = new Date(start);
const endDate = new Date(end);
// Normalize start date based on period type
switch (type) {
case ChartPeriodEnum.Weekly:
// Start of week (Monday) - PostgreSQL DATE_TRUNC('week') uses ISO week (Monday as first day)
const dayOfWeek = current.getDay();
const diff = current.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
current.setDate(diff);
current.setHours(0, 0, 0, 0);
// Also normalize end date to include the week containing it
const endDayOfWeek = endDate.getDay();
const endDiff = endDate.getDate() - endDayOfWeek + (endDayOfWeek === 0 ? -6 : 1);
endDate.setDate(endDiff);
endDate.setHours(23, 59, 59, 999);
break;
case ChartPeriodEnum.Monthly:
// Start of month
current.setDate(1);
current.setHours(0, 0, 0, 0);
// Also normalize end date to include the month containing it
endDate.setDate(1);
endDate.setHours(23, 59, 59, 999);
break;
case ChartPeriodEnum.Daily:
default:
current.setHours(0, 0, 0, 0);
endDate.setHours(23, 59, 59, 999);
}
while (current <= endDate) {
const dateStr = current.toISOString().split('T')[0];
dates.push(dateStr);
// Increment based on period type
switch (type) {
case ChartPeriodEnum.Weekly:
current.setDate(current.getDate() + 7);
break;
case ChartPeriodEnum.Monthly:
current.setMonth(current.getMonth() + 1);
break;
case ChartPeriodEnum.Daily:
default:
current.setDate(current.getDate() + 1);
}
}
return dates;
}
} }
@@ -39,8 +39,8 @@ export class UsersController {
@ApiBody({ type: UpdateUserDto }) @ApiBody({ type: UpdateUserDto })
@Patch('public/user/update') @Patch('public/user/update')
async updateUser(@UserId() userId: string, , @Body() dto: UpdateUserDto) { async updateUser(@UserId() userId: string, @Body() dto: UpdateUserDto) {
const user = await this.userService.updateUser(userId, , dto); const user = await this.userService.updateUser(userId, dto);
return user; return user;
} }