payment
This commit is contained in:
@@ -54,7 +54,7 @@ export class AdminAuthGuard implements CanActivate {
|
||||
secret,
|
||||
});
|
||||
|
||||
if (!payload.adminId || !payload.) {
|
||||
if (!payload.adminId) {
|
||||
this.logger.error('Invalid token payload structure', payload);
|
||||
throw new UnauthorizedException('Invalid token payload');
|
||||
}
|
||||
@@ -69,19 +69,21 @@ export class AdminAuthGuard implements CanActivate {
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||
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)) {
|
||||
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');
|
||||
}
|
||||
|
||||
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) {
|
||||
this.logger.warn('Insufficient permissions', {
|
||||
adminId: payload.adminId,
|
||||
: payload.,
|
||||
required: requiredPermissions,
|
||||
has: adminPermission,
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ export class AuthGuard implements CanActivate {
|
||||
const payload = await this.jwtService.verifyAsync<ITokenPayload>(token, {
|
||||
secret,
|
||||
});
|
||||
if (!payload.userId || !payload.) {
|
||||
if (!payload.userId ) {
|
||||
this.logger.error('Invalid token payload structure', 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 { UserId } from '../../../common/decorators/user-id.decorator';
|
||||
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
|
||||
import { } from '../../../common/decorators/rest-id.decorator';
|
||||
import { UpdatePreferenceDto } from '../dto/update-preference.dto';
|
||||
import { CreatePreferenceDto } from '../dto/create-preference.dto';
|
||||
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 { SuperAdminAuthGuard } from 'src/modules/auth/guards/superAdminAuth.guard';
|
||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
|
||||
@@ -23,174 +20,156 @@ export class NotificationsController {
|
||||
private readonly preferenceService: NotificationPreferenceService,
|
||||
) { }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Get('public/notifications')
|
||||
@ApiOperation({ summary: 'Get user restaurant notifications' })
|
||||
// @UseGuards(AuthGuard)
|
||||
// @ApiBearerAuth()
|
||||
// @Get('public/notifications')
|
||||
// @ApiOperation({ summary: 'Get user restaurant notifications' })
|
||||
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of items to return (default: 50)' })
|
||||
@ApiQuery({
|
||||
name: 'cursor',
|
||||
required: false,
|
||||
type: String,
|
||||
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' })
|
||||
async getUserNotifications(
|
||||
@UserId() userId: string,
|
||||
,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('cursor') cursor?: string,
|
||||
@Query('status') status?: 'seen' | 'unseen',
|
||||
) {
|
||||
return await this.notificationService.findByUserAndRestaurant(
|
||||
userId,
|
||||
,
|
||||
limit ? parseInt(limit.toString(), 10) : 50,
|
||||
cursor,
|
||||
status,
|
||||
);
|
||||
}
|
||||
// @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of items to return (default: 50)' })
|
||||
// @ApiQuery({
|
||||
// name: 'cursor',
|
||||
// required: false,
|
||||
// type: String,
|
||||
// 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' })
|
||||
// async getUserNotifications(
|
||||
// @UserId() userId: string,
|
||||
// @Query('limit') limit?: number,
|
||||
// @Query('cursor') cursor?: string,
|
||||
// @Query('status') status?: 'seen' | 'unseen',
|
||||
// ) {
|
||||
// return await this.notificationService.findByUserAndRestaurant(
|
||||
// userId,
|
||||
// limit ? parseInt(limit.toString(), 10) : 50,
|
||||
// cursor,
|
||||
// status,
|
||||
// );
|
||||
// }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Get('public/notifications/unseen-count')
|
||||
@ApiOperation({ summary: 'Get unseen notifications count for user' })
|
||||
// @UseGuards(AuthGuard)
|
||||
// @ApiBearerAuth()
|
||||
// @Get('public/notifications/unseen-count')
|
||||
// @ApiOperation({ summary: 'Get unseen notifications count for user' })
|
||||
|
||||
async getUserUnseenCount(@UserId() userId: string,) {
|
||||
const count = await this.notificationService.countUnseenByUserAndRestaurant(userId,);
|
||||
return { count };
|
||||
}
|
||||
// async getUserUnseenCount(@UserId() userId: string,) {
|
||||
// const count = await this.notificationService.countUnseenByUserAndRestaurant(userId,);
|
||||
// return { count };
|
||||
// }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Put('public/notifications/:id')
|
||||
@ApiOperation({ summary: 'Read a notification ' })
|
||||
@ApiParam({ name: 'id', description: 'Notification ID' })
|
||||
async readNotificationUser(, @Param('id') id: string, @UserId() userId: string) {
|
||||
await this.notificationService.readNotificationAsUser(id, userId,);
|
||||
return { message: NotificationMessage.READ_SUCCESS };
|
||||
}
|
||||
// @UseGuards(AuthGuard)
|
||||
// @ApiBearerAuth()
|
||||
// @Put('public/notifications/:id')
|
||||
// @ApiOperation({ summary: 'Read a notification ' })
|
||||
// @ApiParam({ name: 'id', description: 'Notification ID' })
|
||||
// async readNotificationUser(, @Param('id') id: string, @UserId() userId: string) {
|
||||
// await this.notificationService.readNotificationAsUser(id, userId,);
|
||||
// return { message: NotificationMessage.READ_SUCCESS };
|
||||
// }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Put('public/notifications/read/all')
|
||||
@ApiOperation({ summary: 'Read all notification ' })
|
||||
async readAllNotificationUser(, @UserId() userId: string) {
|
||||
await this.notificationService.readAllNotifsAsUser(userId,);
|
||||
return { message: NotificationMessage.READ_SUCCESS };
|
||||
}
|
||||
/* ***************** Admin Endpoints ***************** */
|
||||
// @UseGuards(AuthGuard)
|
||||
// @ApiBearerAuth()
|
||||
// @Put('public/notifications/read/all')
|
||||
// @ApiOperation({ summary: 'Read all notification ' })
|
||||
// async readAllNotificationUser(, @UserId() userId: string) {
|
||||
// await this.notificationService.readAllNotifsAsUser(userId,);
|
||||
// return { message: NotificationMessage.READ_SUCCESS };
|
||||
// }
|
||||
// /* ***************** Admin Endpoints ***************** */
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Get('admin/notifications')
|
||||
@ApiOperation({ summary: 'Get Admin notifications' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiQuery({
|
||||
name: 'cursor',
|
||||
required: false,
|
||||
type: String,
|
||||
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' })
|
||||
async getAdminNotifications(
|
||||
@AdminId() adminId: string,
|
||||
,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('cursor') cursor?: string,
|
||||
@Query('status') status?: 'seen' | 'unseen',
|
||||
) {
|
||||
const parsedLimit = limit ? parseInt(limit.toString(), 10) : 50;
|
||||
return await this.notificationService.findByRestaurant(, adminId, parsedLimit, cursor, status);
|
||||
}
|
||||
// @UseGuards(AdminAuthGuard)
|
||||
// @ApiBearerAuth()
|
||||
// @Get('admin/notifications')
|
||||
// @ApiOperation({ summary: 'Get Admin notifications' })
|
||||
// @ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
// @ApiQuery({
|
||||
// name: 'cursor',
|
||||
// required: false,
|
||||
// type: String,
|
||||
// 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' })
|
||||
// async getAdminNotifications(
|
||||
// @AdminId() adminId: string,
|
||||
// ,
|
||||
// @Query('limit') limit?: number,
|
||||
// @Query('cursor') cursor?: string,
|
||||
// @Query('status') status?: 'seen' | 'unseen',
|
||||
// ) {
|
||||
// const parsedLimit = limit ? parseInt(limit.toString(), 10) : 50;
|
||||
// return await this.notificationService.findByRestaurant( adminId, parsedLimit, cursor, status);
|
||||
// }
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Get('admin/notifications/unseen-count')
|
||||
@ApiOperation({ summary: 'Get unseen notifications count for admin' })
|
||||
async getAdminUnseenCount(@AdminId() adminId: string,) {
|
||||
const count = await this.notificationService.countUnseenByRestaurant(adminId,);
|
||||
return { count };
|
||||
}
|
||||
// @UseGuards(AdminAuthGuard)
|
||||
// @ApiBearerAuth()
|
||||
// @Get('admin/notifications/unseen-count')
|
||||
// @ApiOperation({ summary: 'Get unseen notifications count for admin' })
|
||||
// async getAdminUnseenCount(@AdminId() adminId: string,) {
|
||||
// const count = await this.notificationService.countUnseenByRestaurant(adminId,);
|
||||
// return { count };
|
||||
// }
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Put('admin/notifications/:id')
|
||||
@ApiOperation({ summary: 'Read a notification ' })
|
||||
@ApiParam({ name: 'id', description: 'Notification ID' })
|
||||
async readNotificationAdmin(, @AdminId() adminId: string, @Param('id') id: string) {
|
||||
await this.notificationService.readNotificationAdmin(id, adminId,);
|
||||
return { message: NotificationMessage.READ_SUCCESS };
|
||||
}
|
||||
// @UseGuards(AdminAuthGuard)
|
||||
// @ApiBearerAuth()
|
||||
// @Put('admin/notifications/:id')
|
||||
// @ApiOperation({ summary: 'Read a notification ' })
|
||||
// @ApiParam({ name: 'id', description: 'Notification ID' })
|
||||
// async readNotificationAdmin(, @AdminId() adminId: string, @Param('id') id: string) {
|
||||
// await this.notificationService.readNotificationAdmin(id, adminId,);
|
||||
// return { message: NotificationMessage.READ_SUCCESS };
|
||||
// }
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Put('admin/notifications/read/all')
|
||||
@ApiOperation({ summary: 'Read all notification ' })
|
||||
async readAllNotificationAdmin(, @AdminId() adminId: string, @Param('id') id: string) {
|
||||
await this.notificationService.readAllNotifsAsAdmin(adminId,);
|
||||
return { message: NotificationMessage.READ_SUCCESS };
|
||||
}
|
||||
// @UseGuards(AdminAuthGuard)
|
||||
// @ApiBearerAuth()
|
||||
// @Put('admin/notifications/read/all')
|
||||
// @ApiOperation({ summary: 'Read all notification ' })
|
||||
// async readAllNotificationAdmin(, @AdminId() adminId: string, @Param('id') id: string) {
|
||||
// await this.notificationService.readAllNotifsAsAdmin(adminId,);
|
||||
// return { message: NotificationMessage.READ_SUCCESS };
|
||||
// }
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Permissions(Permission.MANAGE_SETTINGS)
|
||||
@Get('admin/notification-preferences')
|
||||
@ApiOperation({ summary: 'Get all notification preferences for a restaurant' })
|
||||
async getPreferences() {
|
||||
return this.preferenceService.findByRestaurant();
|
||||
}
|
||||
// @UseGuards(AdminAuthGuard)
|
||||
// @ApiBearerAuth()
|
||||
// @Permissions(Permission.MANAGE_SETTINGS)
|
||||
// @Get('admin/notification-preferences')
|
||||
// @ApiOperation({ summary: 'Get all notification preferences for a restaurant' })
|
||||
// async getPreferences() {
|
||||
// return this.preferenceService.findByRestaurant();
|
||||
// }
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Permissions(Permission.MANAGE_SETTINGS)
|
||||
@Patch('admin/notification-preferences/:id')
|
||||
@ApiOperation({ summary: 'Update notification channels' })
|
||||
@ApiParam({ name: 'id', description: 'Notification preference ID' })
|
||||
async updatePreference(
|
||||
,
|
||||
@Param('id') preferenceId: string,
|
||||
@Body() dto: UpdatePreferenceDto,
|
||||
) {
|
||||
return this.preferenceService.updatePreference(preferenceId, , dto);
|
||||
}
|
||||
// @UseGuards(AdminAuthGuard)
|
||||
// @ApiBearerAuth()
|
||||
// @Permissions(Permission.MANAGE_SETTINGS)
|
||||
// @Patch('admin/notification-preferences/:id')
|
||||
// @ApiOperation({ summary: 'Update notification channels' })
|
||||
// @ApiParam({ name: 'id', description: 'Notification preference ID' })
|
||||
// async updatePreference(
|
||||
// ,
|
||||
// @Param('id') preferenceId: string,
|
||||
// @Body() dto: UpdatePreferenceDto,
|
||||
// ) {
|
||||
// return this.preferenceService.updatePreference(preferenceId, dto);
|
||||
// }
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Permissions(Permission.MANAGE_SETTINGS)
|
||||
@Post('admin/notification-preferences')
|
||||
@ApiOperation({ summary: 'Create notification preference' })
|
||||
async createPreference(
|
||||
,
|
||||
@Body() dto: CreatePreferenceDto,
|
||||
) {
|
||||
return this.preferenceService.create(, dto);
|
||||
}
|
||||
// @UseGuards(AdminAuthGuard)
|
||||
// @ApiBearerAuth()
|
||||
// @Permissions(Permission.MANAGE_SETTINGS)
|
||||
// @Post('admin/notification-preferences')
|
||||
// @ApiOperation({ summary: 'Create notification preference' })
|
||||
// async createPreference(
|
||||
// ,
|
||||
// @Body() dto: CreatePreferenceDto,
|
||||
// ) {
|
||||
// return this.preferenceService.create(dto);
|
||||
// }
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Get('admin/notifications/sms-usage')
|
||||
@ApiOperation({ summary: 'Get SMS usage for my restaurant' })
|
||||
async getRestaurantSmsUsage() {
|
||||
const smsCount = await this.notificationService.getSmsCountBy();
|
||||
return { smsCount };
|
||||
}
|
||||
// @UseGuards(AdminAuthGuard)
|
||||
// @ApiBearerAuth()
|
||||
// @Get('admin/notifications/sms-usage')
|
||||
// @ApiOperation({ summary: 'Get SMS usage for my restaurant' })
|
||||
// async getRestaurantSmsUsage() {
|
||||
// const smsCount = await this.notificationService.getSmsCountBy();
|
||||
// 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 { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { NotifTitleEnum } from '../interfaces/notification.interface';
|
||||
import { NotifChannelEnum } from '../interfaces/notification.interface';
|
||||
|
||||
@Entity({ tableName: 'notification_preferences' })
|
||||
@Unique({ properties: ['restaurant', 'title'] })
|
||||
export class NotificationPreference extends BaseEntity {
|
||||
@ManyToOne(() => Restaurant)
|
||||
restaurant!: Restaurant;
|
||||
|
||||
@Property()
|
||||
title!: NotifTitleEnum;
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { Entity, Property, ManyToOne, PrimaryKey } from '@mikro-orm/core';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { Entity, Property, PrimaryKey } from '@mikro-orm/core';
|
||||
|
||||
@Entity({ tableName: 'sms_logs' })
|
||||
export class SmsLog {
|
||||
@PrimaryKey()
|
||||
id!: number;
|
||||
|
||||
@ManyToOne(() => Restaurant)
|
||||
restaurant!: Restaurant;
|
||||
|
||||
@Property()
|
||||
phone!: string;
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { IAdminTokenPayload } from '../../auth/interfaces/IToken-payload';
|
||||
|
||||
export interface AuthenticatedSocket extends Socket {
|
||||
adminId?: string;
|
||||
?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -41,16 +40,15 @@ export class WsAdminAuthGuard implements CanActivate {
|
||||
secret,
|
||||
});
|
||||
|
||||
if (!payload.adminId || !payload.) {
|
||||
if (!payload.adminId) {
|
||||
this.logger.error('Invalid token payload structure', payload);
|
||||
throw new WsException('Invalid token payload');
|
||||
}
|
||||
|
||||
// Attach admin info to socket
|
||||
(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;
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { NotifTitleEnum, recipientType } from './notification.interface';
|
||||
export interface SmsNotificationQueueJob {
|
||||
recipient: recipientType;
|
||||
templateId: string;
|
||||
: string;
|
||||
parameters?: Record<string, string>;
|
||||
}
|
||||
export interface PushNotificationQueueJob {
|
||||
@@ -18,7 +17,7 @@ export interface PushNotificationQueueJob {
|
||||
pushTokens?: string[]; // Multiple FCM tokens for bulk push notifications
|
||||
}
|
||||
export interface InAppNotificationQueueJob {
|
||||
recipient: { adminId: string; : string };
|
||||
recipient: { adminId: string; };
|
||||
subject: NotifTitleEnum;
|
||||
body: string;
|
||||
notificationId: string;
|
||||
|
||||
@@ -40,7 +40,6 @@ export interface NotifRequest {
|
||||
// timestamp: Date;
|
||||
// notifType: NotifTypeEnum;
|
||||
// channels: NotifChannelEnum[];
|
||||
: string;
|
||||
recipients: recipientType[];
|
||||
message: NotifRequestMessage;
|
||||
metadata: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { SmsSentEvent } from '../events/sms.events';
|
||||
import { RestRepository } from '../../restaurants/repositories/rest.repository';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { SmsLog } from '../entities/smsLogs.entity';
|
||||
|
||||
@@ -10,7 +9,6 @@ export class SmsListeners {
|
||||
private readonly logger = new Logger(SmsListeners.name);
|
||||
|
||||
constructor(
|
||||
private readonly restRepository: RestRepository,
|
||||
private readonly em: EntityManager,
|
||||
) {
|
||||
}
|
||||
@@ -19,22 +17,11 @@ export class SmsListeners {
|
||||
async handleSmsSent(event: SmsSentEvent) {
|
||||
try {
|
||||
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
|
||||
const smsLog = this.em.create(SmsLog, {
|
||||
restaurant: restaurant,
|
||||
phone: event.phoneNumber,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
@@ -46,7 +33,7 @@ export class SmsListeners {
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to create SMS log for event: ${event.}`,
|
||||
`Failed to create SMS `,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
// SubscribeMessage,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
// MessageBody,
|
||||
// ConnectedSocket,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { Logger, Inject, forwardRef } from '@nestjs/common';
|
||||
@@ -70,8 +68,8 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
||||
// }
|
||||
}
|
||||
|
||||
sendInAppNotification(repipient: { adminId: string; : string }, payload: IInAppNotificationPayload) {
|
||||
const room = this.getRoom(repipient.adminId, repipient.);
|
||||
sendInAppNotification(repipient: { adminId: string; }, payload: IInAppNotificationPayload) {
|
||||
const room = this.getRoom(repipient.adminId,);
|
||||
|
||||
this.logger.log(`Sending in app notification to admin: ${repipient.adminId}`);
|
||||
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}`);
|
||||
}
|
||||
|
||||
private getRoom(adminId: string, : string) {
|
||||
return `restaurant:${}-admin:${adminId}`;
|
||||
private getRoom(adminId: string,) {
|
||||
return `admin:${adminId}`;
|
||||
}
|
||||
|
||||
handleLeaveRoom(client: AuthenticatedSocket) {
|
||||
const room = this.getRoom(client.adminId!, client.!);
|
||||
const room = this.getRoom(client.adminId!,);
|
||||
void client.leave(room);
|
||||
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) left room: ${room}`);
|
||||
void client.emit('left', { room, message: 'Successfully Admin left room' });
|
||||
}
|
||||
|
||||
handleJoinRoom(client: AuthenticatedSocket) {
|
||||
const room = this.getRoom(client.adminId!, client.!);
|
||||
const room = this.getRoom(client.adminId!,);
|
||||
void client.join(room);
|
||||
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) joined room: ${room}`);
|
||||
void client.emit('joined', { room, message: 'Successfully Admin joined room' });
|
||||
|
||||
@@ -9,69 +9,5 @@ export class SmsLogRepository extends EntityRepository<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 { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { NotificationPreference } from '../entities/notification-preference.entity';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { CreatePreferenceDto } from '../dto/create-preference.dto';
|
||||
import { UpdatePreferenceDto } from '../dto/update-preference.dto';
|
||||
import { NotifTitleEnum } from '../interfaces/notification.interface';
|
||||
@@ -10,34 +9,34 @@ import { NotifTitleEnum } from '../interfaces/notification.interface';
|
||||
export class NotificationPreferenceService {
|
||||
constructor(private readonly em: EntityManager) { }
|
||||
|
||||
async create(: string, dto: CreatePreferenceDto): Promise<NotificationPreference> {
|
||||
const restaurant = await this.em.findOne(Restaurant, { id: });
|
||||
if (!restaurant) {
|
||||
throw new NotFoundException('Restaurant not found');
|
||||
}
|
||||
// async create( dto: CreatePreferenceDto): Promise<NotificationPreference> {
|
||||
// const restaurant = await this.em.findOne(Restaurant, { id: });
|
||||
// if (!restaurant) {
|
||||
// throw new NotFoundException('Restaurant not found');
|
||||
// }
|
||||
|
||||
const preference = this.em.create(NotificationPreference, {
|
||||
restaurant,
|
||||
channels: dto.channels,
|
||||
title: dto.title,
|
||||
});
|
||||
// const preference = this.em.create(NotificationPreference, {
|
||||
// restaurant,
|
||||
// channels: dto.channels,
|
||||
// title: dto.title,
|
||||
// });
|
||||
|
||||
await this.em.persistAndFlush(preference);
|
||||
return preference;
|
||||
}
|
||||
// await this.em.persistAndFlush(preference);
|
||||
// return preference;
|
||||
// }
|
||||
|
||||
async findByRestaurant(: string): Promise<NotificationPreference[]> {
|
||||
return this.em.find(NotificationPreference, {
|
||||
restaurant: { id: },
|
||||
});
|
||||
}
|
||||
// async findByRestaurant(: string): Promise<NotificationPreference[]> {
|
||||
// return this.em.find(NotificationPreference, {
|
||||
// restaurant: { id: },
|
||||
// });
|
||||
// }
|
||||
|
||||
async findByRestaurantAndType(: string, title: NotifTitleEnum): Promise<NotificationPreference | null> {
|
||||
return this.em.findOne(NotificationPreference, {
|
||||
restaurant: { id: },
|
||||
title,
|
||||
});
|
||||
}
|
||||
// async findByRestaurantAndType(: string, title: NotifTitleEnum): Promise<NotificationPreference | null> {
|
||||
// return this.em.findOne(NotificationPreference, {
|
||||
// restaurant: { id: },
|
||||
// title,
|
||||
// });
|
||||
// }
|
||||
|
||||
// async updateEnabled(
|
||||
// : string,
|
||||
@@ -54,32 +53,32 @@ export class NotificationPreferenceService {
|
||||
// return preference;
|
||||
// }
|
||||
|
||||
async updatePreference(
|
||||
preferenceId: string,
|
||||
: string,
|
||||
dto: UpdatePreferenceDto,
|
||||
): Promise<NotificationPreference> {
|
||||
const preference = await this.em.findOne(NotificationPreference, {
|
||||
id: preferenceId,
|
||||
restaurant: { id: },
|
||||
});
|
||||
if (!preference) {
|
||||
throw new NotFoundException('Notification preference not found');
|
||||
}
|
||||
// async updatePreference(
|
||||
// preferenceId: string,
|
||||
// : string,
|
||||
// dto: UpdatePreferenceDto,
|
||||
// ): Promise<NotificationPreference> {
|
||||
// const preference = await this.em.findOne(NotificationPreference, {
|
||||
// id: preferenceId,
|
||||
// restaurant: { id: },
|
||||
// });
|
||||
// if (!preference) {
|
||||
// throw new NotFoundException('Notification preference not found');
|
||||
// }
|
||||
|
||||
this.em.assign(preference, dto);
|
||||
await this.em.persistAndFlush(preference);
|
||||
return preference;
|
||||
}
|
||||
// this.em.assign(preference, dto);
|
||||
// await this.em.persistAndFlush(preference);
|
||||
// return preference;
|
||||
// }
|
||||
|
||||
async delete(: string, preferenceId: string): Promise<void> {
|
||||
const preference = await this.em.findOne(NotificationPreference, {
|
||||
restaurant: { id: },
|
||||
id: preferenceId,
|
||||
});
|
||||
if (!preference) {
|
||||
throw new NotFoundException('Notification preference not found');
|
||||
}
|
||||
await this.em.removeAndFlush(preference);
|
||||
}
|
||||
// async delete(: string, preferenceId: string): Promise<void> {
|
||||
// const preference = await this.em.findOne(NotificationPreference, {
|
||||
// restaurant: { id: },
|
||||
// id: preferenceId,
|
||||
// });
|
||||
// if (!preference) {
|
||||
// throw new NotFoundException('Notification preference not found');
|
||||
// }
|
||||
// await this.em.removeAndFlush(preference);
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -21,259 +21,259 @@ export class NotificationService {
|
||||
private readonly smsLogRepository: SmsLogRepository,
|
||||
) { }
|
||||
|
||||
async sendNotification(params: NotifRequest): Promise<Notification[]> {
|
||||
const { recipients, message, metadata, } = params;
|
||||
// async sendNotification(params: NotifRequest): Promise<Notification[]> {
|
||||
// const { recipients, message, metadata, } = params;
|
||||
|
||||
// create Database notifications
|
||||
const notifications = await this.createAdminBulkNotifications(
|
||||
recipients.map(recipient => ({
|
||||
title: message.title,
|
||||
content: message.content,
|
||||
adminId: 'adminId' in recipient ? recipient.adminId : null,
|
||||
userId: 'userId' in recipient ? recipient.userId : null,
|
||||
})),
|
||||
);
|
||||
// // create Database notifications
|
||||
// const notifications = await this.createAdminBulkNotifications(
|
||||
// recipients.map(recipient => ({
|
||||
// title: message.title,
|
||||
// content: message.content,
|
||||
// adminId: 'adminId' in recipient ? recipient.adminId : null,
|
||||
// userId: 'userId' in recipient ? recipient.userId : null,
|
||||
// })),
|
||||
// );
|
||||
|
||||
// get admin prefrences
|
||||
const preference = await this.preferenceService.findByRestaurantAndType(, message.title);
|
||||
// // get admin prefrences
|
||||
// const preference = await this.preferenceService.findByRestaurantAndType(, message.title);
|
||||
|
||||
if(preference?.channels?.length === 0) {
|
||||
this.logger.warn(`Notification type is NONE for restaurant ${}, title ${message.title}`);
|
||||
return notifications;
|
||||
}
|
||||
// if(preference?.channels?.length === 0) {
|
||||
// this.logger.warn(`Notification type is NONE for restaurant ${}, title ${message.title}`);
|
||||
// return notifications;
|
||||
// }
|
||||
|
||||
// send in app notification
|
||||
if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) {
|
||||
await this.queueService.addBulkInAppNotifications(
|
||||
notifications.map(notification => ({
|
||||
recipient: { adminId: notification.admin?.id || '', },
|
||||
subject: message.title,
|
||||
body: message.content,
|
||||
notificationId: notification.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
// // send in app notification
|
||||
// if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) {
|
||||
// await this.queueService.addBulkInAppNotifications(
|
||||
// notifications.map(notification => ({
|
||||
// recipient: { adminId: notification.admin?.id || '', },
|
||||
// subject: message.title,
|
||||
// body: message.content,
|
||||
// notificationId: notification.id,
|
||||
// })),
|
||||
// );
|
||||
// }
|
||||
|
||||
// add sms notifications to queue
|
||||
if (preference?.channels?.includes(NotifChannelEnum.SMS)) {
|
||||
await this.queueService.addBulkSmsNotifications(
|
||||
recipients.map(recipient => ({
|
||||
recipient,
|
||||
templateId: message.sms.templateId,
|
||||
parameters: message.sms.parameters,
|
||||
})),
|
||||
);
|
||||
}
|
||||
// // add sms notifications to queue
|
||||
// if (preference?.channels?.includes(NotifChannelEnum.SMS)) {
|
||||
// await this.queueService.addBulkSmsNotifications(
|
||||
// recipients.map(recipient => ({
|
||||
// recipient,
|
||||
// templateId: message.sms.templateId,
|
||||
// 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(
|
||||
params: {
|
||||
// async createAdminBulkNotifications(
|
||||
// params: {
|
||||
|
||||
title: NotifTitleEnum;
|
||||
content: string;
|
||||
adminId: string | null;
|
||||
userId: string | null;
|
||||
}[],
|
||||
): Promise < Notification[] > {
|
||||
const notifications = params.map(param => {
|
||||
return this.em.create(Notification, {
|
||||
admin: param.adminId,
|
||||
user: param.userId && param.userId.trim() !== '' ? param.userId : null,
|
||||
title: param.title,
|
||||
content: param.content,
|
||||
});
|
||||
});
|
||||
await this.em.persistAndFlush(notifications);
|
||||
return notifications;
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise < Notification > {
|
||||
const notification = await this.em.findOne(Notification, { id }, { populate: ['restaurant', 'user'] });
|
||||
if(!notification) {
|
||||
throw new NotFoundException('Notification not found');
|
||||
}
|
||||
return notification;
|
||||
}
|
||||
|
||||
async findByRestaurant(
|
||||
: string,
|
||||
adminId: string,
|
||||
limit = 50,
|
||||
cursor ?: string,
|
||||
status ?: 'seen' | 'unseen',
|
||||
): Promise < { data: Notification[]; nextCursor: string | null } > {
|
||||
const where: FilterQuery<Notification> = {
|
||||
restaurant: { id: },
|
||||
admin: { id: adminId },
|
||||
};
|
||||
|
||||
// Filter by status (seen/unseen)
|
||||
if (status === 'seen') {
|
||||
where.seenAt = { $ne: null };
|
||||
} else if (status === 'unseen') {
|
||||
where.seenAt = null;
|
||||
}
|
||||
|
||||
// Cursor-based pagination: if cursor is provided, get items with id < cursor
|
||||
if (cursor) {
|
||||
where.id = { $lt: cursor };
|
||||
}
|
||||
|
||||
const notifications = await this.em.find(Notification, where, {
|
||||
orderBy: { createdAt: 'DESC', id: 'DESC' },
|
||||
limit: limit + 1, // fetch one extra to determine next page
|
||||
populate: ['user'],
|
||||
});
|
||||
|
||||
const hasNextPage = notifications.length > limit;
|
||||
const data = hasNextPage ? notifications.slice(0, limit) : notifications;
|
||||
const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
|
||||
|
||||
return {
|
||||
data,
|
||||
nextCursor: nextCursor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async findByUserAndRestaurant(
|
||||
userId: string,
|
||||
: string,
|
||||
limit = 50,
|
||||
cursor ?: string,
|
||||
status ?: 'seen' | 'unseen',
|
||||
): Promise < { data: Notification[]; nextCursor: string | null } > {
|
||||
const where: FilterQuery<Notification> = {
|
||||
user: { id: userId },
|
||||
restaurant: { id: },
|
||||
};
|
||||
|
||||
// Filter by status (seen/unseen)
|
||||
if (status === 'seen') {
|
||||
where.seenAt = { $ne: null };
|
||||
} else if (status === 'unseen') {
|
||||
where.seenAt = null;
|
||||
}
|
||||
|
||||
// Cursor-based pagination: if cursor is provided, get items with id < cursor (since ULIDs are time-ordered)
|
||||
if (cursor) {
|
||||
where.id = { $lt: cursor };
|
||||
}
|
||||
|
||||
const notifications = await this.em.find(Notification, where, {
|
||||
orderBy: { createdAt: 'DESC', id: 'DESC' },
|
||||
limit: limit + 1, // Fetch one extra to determine if there's a next page
|
||||
});
|
||||
|
||||
// Check if there's a next page
|
||||
const hasNextPage = notifications.length > limit;
|
||||
const data = hasNextPage ? notifications.slice(0, limit) : notifications;
|
||||
const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
|
||||
|
||||
return {
|
||||
data,
|
||||
nextCursor: nextCursor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async readNotificationAdmin(id: string, adminId: string, : string): Promise < void> {
|
||||
const notification = await this.em.findOne(Notification, {
|
||||
id,
|
||||
admin: { id: adminId },
|
||||
restaurant: { id: },
|
||||
});
|
||||
if(!notification) {
|
||||
throw new NotFoundException('Notification not found');
|
||||
}
|
||||
notification.seenAt = new Date();
|
||||
await this.em.persistAndFlush(notification);
|
||||
}
|
||||
async readNotificationAsUser(id: string, userId: string, : string): Promise < void> {
|
||||
const notification = await this.em.findOne(Notification, {
|
||||
id,
|
||||
user: { id: userId },
|
||||
restaurant: { id: },
|
||||
});
|
||||
if(!notification) {
|
||||
throw new NotFoundException('Notification not found');
|
||||
}
|
||||
notification.seenAt = new Date();
|
||||
await this.em.persistAndFlush(notification);
|
||||
}
|
||||
|
||||
async findByRestaurantAndType(: string, title: NotifTitleEnum, limit = 50): Promise < Notification[] > {
|
||||
return this.em.find(
|
||||
Notification,
|
||||
{
|
||||
restaurant: { id: },
|
||||
title,
|
||||
},
|
||||
{
|
||||
orderBy: { createdAt: 'DESC' },
|
||||
limit,
|
||||
populate: ['user'],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async findByAdminAndRestaurant(adminId: string, : string, limit = 50): Promise < Notification[] > {
|
||||
return this.em.find(
|
||||
Notification,
|
||||
{ admin: { id: adminId }, restaurant: { id: } },
|
||||
{
|
||||
orderBy: { createdAt: 'DESC' },
|
||||
limit,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async countUnseenByUserAndRestaurant(userId: string, : string): Promise < number > {
|
||||
const where: FilterQuery<Notification> = {
|
||||
user: { id: userId },
|
||||
restaurant: { id: },
|
||||
seenAt: null,
|
||||
};
|
||||
return this.em.count(Notification, where);
|
||||
}
|
||||
|
||||
async countUnseenByRestaurant(adminId: string, : string): Promise < number > {
|
||||
const where: FilterQuery<Notification> = {
|
||||
admin: { id: adminId },
|
||||
restaurant: { id: },
|
||||
seenAt: null,
|
||||
};
|
||||
return this.em.count(Notification, where);
|
||||
}
|
||||
|
||||
readAllNotifsAsUser(userId: string, : string): Promise < number > {
|
||||
const where: FilterQuery<Notification> = {
|
||||
user: { id: userId },
|
||||
restaurant: { id: },
|
||||
seenAt: null,
|
||||
};
|
||||
return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
|
||||
}
|
||||
|
||||
readAllNotifsAsAdmin(adminId: string, : string): Promise < number > {
|
||||
const where: FilterQuery<Notification> = {
|
||||
admin: { id: adminId },
|
||||
restaurant: { id: },
|
||||
seenAt: null,
|
||||
};
|
||||
return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
|
||||
}
|
||||
|
||||
async getSmsCountByRestaurant(
|
||||
page: number = 1,
|
||||
limit: number = 10,
|
||||
): Promise < PaginatedResult < { : string; restaurantName: string; smsCount: number } >> {
|
||||
return this.smsLogRepository.getSmsCountByRestaurant(page, limit);
|
||||
}
|
||||
|
||||
async getSmsCountBy(: string): Promise < number > {
|
||||
return this.smsLogRepository.getSmsCountBy();
|
||||
}
|
||||
// title: NotifTitleEnum;
|
||||
// content: string;
|
||||
// adminId: string | null;
|
||||
// userId: string | null;
|
||||
// }[],
|
||||
// ): Promise < Notification[] > {
|
||||
// const notifications = params.map(param => {
|
||||
// return this.em.create(Notification, {
|
||||
// admin: param.adminId,
|
||||
// user: param.userId && param.userId.trim() !== '' ? param.userId : null,
|
||||
// title: param.title,
|
||||
// content: param.content,
|
||||
// });
|
||||
// });
|
||||
// await this.em.persistAndFlush(notifications);
|
||||
// return notifications;
|
||||
// }
|
||||
|
||||
// async findOne(id: string): Promise < Notification > {
|
||||
// const notification = await this.em.findOne(Notification, { id }, { populate: ['restaurant', 'user'] });
|
||||
// if(!notification) {
|
||||
// throw new NotFoundException('Notification not found');
|
||||
// }
|
||||
// return notification;
|
||||
// }
|
||||
|
||||
// async findByRestaurant(
|
||||
// : string,
|
||||
// adminId: string,
|
||||
// limit = 50,
|
||||
// cursor ?: string,
|
||||
// status ?: 'seen' | 'unseen',
|
||||
// ): Promise < { data: Notification[]; nextCursor: string | null } > {
|
||||
// const where: FilterQuery<Notification> = {
|
||||
// restaurant: { id: },
|
||||
// admin: { id: adminId },
|
||||
// };
|
||||
|
||||
// // Filter by status (seen/unseen)
|
||||
// if (status === 'seen') {
|
||||
// where.seenAt = { $ne: null };
|
||||
// } else if (status === 'unseen') {
|
||||
// where.seenAt = null;
|
||||
// }
|
||||
|
||||
// // Cursor-based pagination: if cursor is provided, get items with id < cursor
|
||||
// if (cursor) {
|
||||
// where.id = { $lt: cursor };
|
||||
// }
|
||||
|
||||
// const notifications = await this.em.find(Notification, where, {
|
||||
// orderBy: { createdAt: 'DESC', id: 'DESC' },
|
||||
// limit: limit + 1, // fetch one extra to determine next page
|
||||
// populate: ['user'],
|
||||
// });
|
||||
|
||||
// const hasNextPage = notifications.length > limit;
|
||||
// const data = hasNextPage ? notifications.slice(0, limit) : notifications;
|
||||
// const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
|
||||
|
||||
// return {
|
||||
// data,
|
||||
// nextCursor: nextCursor ?? null,
|
||||
// };
|
||||
// }
|
||||
|
||||
// async findByUserAndRestaurant(
|
||||
// userId: string,
|
||||
// : string,
|
||||
// limit = 50,
|
||||
// cursor ?: string,
|
||||
// status ?: 'seen' | 'unseen',
|
||||
// ): Promise < { data: Notification[]; nextCursor: string | null } > {
|
||||
// const where: FilterQuery<Notification> = {
|
||||
// user: { id: userId },
|
||||
// restaurant: { id: },
|
||||
// };
|
||||
|
||||
// // Filter by status (seen/unseen)
|
||||
// if (status === 'seen') {
|
||||
// where.seenAt = { $ne: null };
|
||||
// } else if (status === 'unseen') {
|
||||
// where.seenAt = null;
|
||||
// }
|
||||
|
||||
// // Cursor-based pagination: if cursor is provided, get items with id < cursor (since ULIDs are time-ordered)
|
||||
// if (cursor) {
|
||||
// where.id = { $lt: cursor };
|
||||
// }
|
||||
|
||||
// const notifications = await this.em.find(Notification, where, {
|
||||
// orderBy: { createdAt: 'DESC', id: 'DESC' },
|
||||
// limit: limit + 1, // Fetch one extra to determine if there's a next page
|
||||
// });
|
||||
|
||||
// // Check if there's a next page
|
||||
// const hasNextPage = notifications.length > limit;
|
||||
// const data = hasNextPage ? notifications.slice(0, limit) : notifications;
|
||||
// const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
|
||||
|
||||
// return {
|
||||
// data,
|
||||
// nextCursor: nextCursor ?? null,
|
||||
// };
|
||||
// }
|
||||
|
||||
// async readNotificationAdmin(id: string, adminId: string, : string): Promise < void> {
|
||||
// const notification = await this.em.findOne(Notification, {
|
||||
// id,
|
||||
// admin: { id: adminId },
|
||||
// restaurant: { id: },
|
||||
// });
|
||||
// if(!notification) {
|
||||
// throw new NotFoundException('Notification not found');
|
||||
// }
|
||||
// notification.seenAt = new Date();
|
||||
// await this.em.persistAndFlush(notification);
|
||||
// }
|
||||
// async readNotificationAsUser(id: string, userId: string, : string): Promise < void> {
|
||||
// const notification = await this.em.findOne(Notification, {
|
||||
// id,
|
||||
// user: { id: userId },
|
||||
// restaurant: { id: },
|
||||
// });
|
||||
// if(!notification) {
|
||||
// throw new NotFoundException('Notification not found');
|
||||
// }
|
||||
// notification.seenAt = new Date();
|
||||
// await this.em.persistAndFlush(notification);
|
||||
// }
|
||||
|
||||
// async findByRestaurantAndType(: string, title: NotifTitleEnum, limit = 50): Promise < Notification[] > {
|
||||
// return this.em.find(
|
||||
// Notification,
|
||||
// {
|
||||
// restaurant: { id: },
|
||||
// title,
|
||||
// },
|
||||
// {
|
||||
// orderBy: { createdAt: 'DESC' },
|
||||
// limit,
|
||||
// populate: ['user'],
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
|
||||
// async findByAdminAndRestaurant(adminId: string, : string, limit = 50): Promise < Notification[] > {
|
||||
// return this.em.find(
|
||||
// Notification,
|
||||
// { admin: { id: adminId }, restaurant: { id: } },
|
||||
// {
|
||||
// orderBy: { createdAt: 'DESC' },
|
||||
// limit,
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
|
||||
// async countUnseenByUserAndRestaurant(userId: string, : string): Promise < number > {
|
||||
// const where: FilterQuery<Notification> = {
|
||||
// user: { id: userId },
|
||||
// restaurant: { id: },
|
||||
// seenAt: null,
|
||||
// };
|
||||
// return this.em.count(Notification, where);
|
||||
// }
|
||||
|
||||
// async countUnseenByRestaurant(adminId: string, : string): Promise < number > {
|
||||
// const where: FilterQuery<Notification> = {
|
||||
// admin: { id: adminId },
|
||||
// restaurant: { id: },
|
||||
// seenAt: null,
|
||||
// };
|
||||
// return this.em.count(Notification, where);
|
||||
// }
|
||||
|
||||
// readAllNotifsAsUser(userId: string, : string): Promise < number > {
|
||||
// const where: FilterQuery<Notification> = {
|
||||
// user: { id: userId },
|
||||
// restaurant: { id: },
|
||||
// seenAt: null,
|
||||
// };
|
||||
// return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
|
||||
// }
|
||||
|
||||
// readAllNotifsAsAdmin(adminId: string, : string): Promise < number > {
|
||||
// const where: FilterQuery<Notification> = {
|
||||
// admin: { id: adminId },
|
||||
// restaurant: { id: },
|
||||
// seenAt: null,
|
||||
// };
|
||||
// return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
|
||||
// }
|
||||
|
||||
// async getSmsCountByRestaurant(
|
||||
// page: number = 1,
|
||||
// limit: number = 10,
|
||||
// ): Promise < PaginatedResult < { : string; restaurantName: string; smsCount: number } >> {
|
||||
// return this.smsLogRepository.getSmsCountByRestaurant(page, limit);
|
||||
// }
|
||||
|
||||
// async getSmsCountBy(: string): Promise < number > {
|
||||
// return this.smsLogRepository.getSmsCountBy();
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -2,12 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
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()
|
||||
export class OrdersCrone {
|
||||
@@ -15,173 +10,8 @@ export class OrdersCrone {
|
||||
|
||||
constructor(
|
||||
private readonly em: EntityManager,
|
||||
private readonly inventoryService: InventoryService,
|
||||
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 { Order } from './order.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: ['product'] })
|
||||
export class OrderItem extends BaseEntity {
|
||||
@PrimaryKey({ type: 'bigint', autoincrement: true })
|
||||
id: bigint
|
||||
|
||||
@ManyToOne(() => Order)
|
||||
order!: Order;
|
||||
|
||||
|
||||
@@ -7,17 +7,22 @@ import {
|
||||
Collection,
|
||||
Cascade,
|
||||
Enum,
|
||||
PrimaryKey,
|
||||
} from '@mikro-orm/core';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { OrderStatus } from '../interface/order.interface';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { OrderItem } from './order-item.entity';
|
||||
import { Payment } from 'src/modules/payment/entities/payment.entity';
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
@Entity({ tableName: 'orders' })
|
||||
@Index({ properties: ['user', 'status'] })
|
||||
@Index({ properties: ['status'] })
|
||||
export class Order extends BaseEntity {
|
||||
@PrimaryKey({type:'string',columnType:'char(26)'})
|
||||
id:string=ulid()
|
||||
|
||||
@ManyToOne(() => User)
|
||||
user!: User;
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ import { OrderRepository } from '../repositories/order.repository';
|
||||
import { OrderStatus } from '../interface/order.interface';
|
||||
import { PaymentMethodEnum } from 'src/modules/payment/interface/payment';
|
||||
import { UserService } from 'src/modules/user/providers/user.service';
|
||||
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/user/interface/wallet';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class OrderListeners {
|
||||
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';
|
||||
}
|
||||
|
||||
private getStatusFarsi(status: OrderStatus): string {
|
||||
const statusMap: Record<OrderStatus, string> = {
|
||||
[OrderStatus.PENDING_PAYMENT]: 'در انتظار پرداخت',
|
||||
[OrderStatus.PAID]: 'پرداخت شده',
|
||||
[OrderStatus.PREPARING]: 'در حال آمادهسازی',
|
||||
[OrderStatus.DELIVERED_TO_RECEPTIONIST]: 'تحویل به پذیرش',
|
||||
[OrderStatus.DELIVERED_TO_WAITER]: 'تحویل به گارسون',
|
||||
[OrderStatus.SHIPPED]: 'ارسال شده',
|
||||
[OrderStatus.COMPLETED]: 'تکمیل شده',
|
||||
[OrderStatus.CANCELED]: 'لغو شده',
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
// private getStatusFarsi(status: OrderStatus): string {
|
||||
// const statusMap: Record<OrderStatus, string> = {
|
||||
// [OrderStatus.PENDING_PAYMENT]: 'در انتظار پرداخت',
|
||||
// [OrderStatus.PAID]: 'پرداخت شده',
|
||||
// [OrderStatus.PREPARING]: 'در حال آمادهسازی',
|
||||
// [OrderStatus.DELIVERED_TO_RECEPTIONIST]: 'تحویل به پذیرش',
|
||||
// [OrderStatus.DELIVERED_TO_WAITER]: 'تحویل به گارسون',
|
||||
// [OrderStatus.SHIPPED]: 'ارسال شده',
|
||||
// [OrderStatus.COMPLETED]: 'تکمیل شده',
|
||||
// [OrderStatus.CANCELED]: 'لغو شده',
|
||||
// };
|
||||
// return statusMap[status] || status;
|
||||
// }
|
||||
|
||||
@OnEvent(OrderCreatedEvent.name)
|
||||
async handleOrderCreated(event: OrderCreatedEvent) {
|
||||
try {
|
||||
this.logger.log(
|
||||
`Order created event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||
);
|
||||
// @OnEvent(OrderCreatedEvent.name)
|
||||
// async handleOrderCreated(event: OrderCreatedEvent) {
|
||||
// try {
|
||||
// this.logger.log(
|
||||
// `Order created event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||
// );
|
||||
|
||||
const order = await this.OrderRepository.findOne(event.orderId);
|
||||
if (order?.paymentMethod.method === PaymentMethodEnum.Online) {
|
||||
return;
|
||||
}
|
||||
// const order = await this.OrderRepository.findOne(event.orderId);
|
||||
// if (order?.paymentMethod.method === PaymentMethodEnum.Online) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
|
||||
// get admnin os restuaraant that have order permissuins
|
||||
const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS);
|
||||
const recipients = admins.map(admin => ({
|
||||
adminId: admin.id,
|
||||
}));
|
||||
// // get admnin os restuaraant that have order permissuins
|
||||
// const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS);
|
||||
// const recipients = admins.map(admin => ({
|
||||
// adminId: admin.id,
|
||||
// }));
|
||||
|
||||
await this.notificationService.sendNotification({
|
||||
: event.,
|
||||
message: {
|
||||
title: NotifTitleEnum.ORDER_CREATED,
|
||||
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||
sms: {
|
||||
templateId: this.orderCreatedSmsTemplateId,
|
||||
parameters: {
|
||||
orderNumber: event.orderNumber,
|
||||
total: event.total.toString(),
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `سفارش جدید`,
|
||||
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||
icon: `/`,
|
||||
action: {
|
||||
type: NotifTitleEnum.ORDER_CREATED,
|
||||
url: `/`,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send notification for order created event: ${event.}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
// await this.notificationService.sendNotification({
|
||||
|
||||
// message: {
|
||||
// title: NotifTitleEnum.ORDER_CREATED,
|
||||
// content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||
// sms: {
|
||||
// templateId: this.orderCreatedSmsTemplateId,
|
||||
// parameters: {
|
||||
// orderNumber: event.orderNumber,
|
||||
// total: event.total.toString(),
|
||||
// },
|
||||
// },
|
||||
// pushNotif: {
|
||||
// title: `سفارش جدید`,
|
||||
// content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||
// icon: `/`,
|
||||
// action: {
|
||||
// type: NotifTitleEnum.ORDER_CREATED,
|
||||
// url: `/`,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// recipients,
|
||||
// metadata: {
|
||||
// priority: 1,
|
||||
// },
|
||||
// });
|
||||
// } catch (error) {
|
||||
// this.logger.error(
|
||||
// `Failed to send notification for order created event: ${event.}`,
|
||||
// error instanceof Error ? error.stack : String(error),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
@OnEvent(OrderStatusChangedEvent.name)
|
||||
async handleOrderStatusChanged(event: OrderStatusChangedEvent) {
|
||||
try {
|
||||
this.logger.log(
|
||||
`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
|
||||
const recipients = [
|
||||
{
|
||||
userId: event.userId,
|
||||
},
|
||||
];
|
||||
if (event.newStatus === OrderStatus.COMPLETED) {
|
||||
// @OnEvent(OrderStatusChangedEvent.name)
|
||||
// async handleOrderStatusChanged(event: OrderStatusChangedEvent) {
|
||||
// try {
|
||||
// this.logger.log(
|
||||
// `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
|
||||
// const recipients = [
|
||||
// {
|
||||
// userId: event.userId,
|
||||
// },
|
||||
// ];
|
||||
// if (event.newStatus === OrderStatus.COMPLETED) {
|
||||
|
||||
if (!event?.userId) {
|
||||
this.logger.log(
|
||||
`User not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||
);
|
||||
}
|
||||
// if (!event?.userId) {
|
||||
// this.logger.log(
|
||||
// `User not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||
// );
|
||||
// }
|
||||
|
||||
// const restaurant = await this.RestaurantRepository.findOne(event.);
|
||||
// if (!restaurant) {
|
||||
// this.logger.log(
|
||||
// `Restaurant not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
// const score = restaurant.score;
|
||||
// if (!score) {
|
||||
// this.logger.log(
|
||||
// `Score not found for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
// // const restaurant = await this.RestaurantRepository.findOne(event.);
|
||||
// // if (!restaurant) {
|
||||
// // this.logger.log(
|
||||
// // `Restaurant not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||
// // );
|
||||
// // return;
|
||||
// // }
|
||||
// // const score = restaurant.score;
|
||||
// // if (!score) {
|
||||
// // this.logger.log(
|
||||
// // `Score not found for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||
// // );
|
||||
// // return;
|
||||
// // }
|
||||
|
||||
// increase score for user
|
||||
// const order = await this.OrderRepository.findOne(event.orderId);
|
||||
// if (!order) {
|
||||
// this.logger.log(
|
||||
// `Order not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
// this.userService.createWalletTransaction(event.userId, event., {
|
||||
// amount: order.subTotal,
|
||||
// type: WalletTransactionType.CREDIT,
|
||||
// reason: WalletTransactionReason.ORDER_COMPLETED_DEPOSIT,
|
||||
// });
|
||||
// // increase score for user
|
||||
// // const order = await this.OrderRepository.findOne(event.orderId);
|
||||
// // if (!order) {
|
||||
// // this.logger.log(
|
||||
// // `Order not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||
// // );
|
||||
// // return;
|
||||
// // }
|
||||
// // this.userService.createWalletTransaction(event.userId, event., {
|
||||
// // amount: order.subTotal,
|
||||
// // type: WalletTransactionType.CREDIT,
|
||||
// // reason: WalletTransactionReason.ORDER_COMPLETED_DEPOSIT,
|
||||
// // });
|
||||
|
||||
await this.notificationService.sendNotification({
|
||||
: event.,
|
||||
message: {
|
||||
title: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
content: `لطفابرای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`,
|
||||
sms: {
|
||||
templateId: this.orderCreatedSmsTemplateId,
|
||||
parameters: {
|
||||
orderNumber: event.orderNumber,
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `تغییر وضعیت سفارش`,
|
||||
content: `لطفا برای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`,
|
||||
icon: `/`,
|
||||
action: {
|
||||
type: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
url: ``,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.notificationService.sendNotification({
|
||||
: event.,
|
||||
message: {
|
||||
title: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`,
|
||||
sms: {
|
||||
templateId: this.orderStatusChangedSmsTemplateId,
|
||||
parameters: {
|
||||
orderNumber: event.orderNumber,
|
||||
status: this.getStatusFarsi(event.newStatus),
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `تغییر وضعیت سفارش`,
|
||||
content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`,
|
||||
icon: `/`,
|
||||
action: {
|
||||
type: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
url: ``,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send notification for order status changed event: ${event.}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
// await this.notificationService.sendNotification({
|
||||
// : event.,
|
||||
// message: {
|
||||
// title: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
// content: `لطفابرای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`,
|
||||
// sms: {
|
||||
// templateId: this.orderCreatedSmsTemplateId,
|
||||
// parameters: {
|
||||
// orderNumber: event.orderNumber,
|
||||
// },
|
||||
// },
|
||||
// pushNotif: {
|
||||
// title: `تغییر وضعیت سفارش`,
|
||||
// content: `لطفا برای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`,
|
||||
// icon: `/`,
|
||||
// action: {
|
||||
// type: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
// url: ``,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// recipients,
|
||||
// metadata: {
|
||||
// priority: 1,
|
||||
// },
|
||||
// });
|
||||
// } else {
|
||||
// await this.notificationService.sendNotification({
|
||||
// : event.,
|
||||
// message: {
|
||||
// title: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
// content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`,
|
||||
// sms: {
|
||||
// templateId: this.orderStatusChangedSmsTemplateId,
|
||||
// parameters: {
|
||||
// orderNumber: event.orderNumber,
|
||||
// status: this.getStatusFarsi(event.newStatus),
|
||||
// },
|
||||
// },
|
||||
// pushNotif: {
|
||||
// title: `تغییر وضعیت سفارش`,
|
||||
// content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`,
|
||||
// icon: `/`,
|
||||
// action: {
|
||||
// type: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
// url: ``,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// recipients,
|
||||
// metadata: {
|
||||
// priority: 1,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// } catch (error) {
|
||||
// this.logger.error(
|
||||
// `Failed to send notification for order status changed event: ${event.}`,
|
||||
// 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 { OrderStatus } from '../interface/order.interface';
|
||||
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payment/interface/payment';
|
||||
import { Review } from '../../review/entities/review.entity';
|
||||
|
||||
|
||||
type FindOrdersOpts = {
|
||||
page?: number;
|
||||
@@ -31,7 +31,7 @@ export class OrderRepository extends EntityRepository<Order> {
|
||||
* Find orders with pagination and optional filters.
|
||||
* Supports: statuses, paymentStatus, search (orderNumber), date range, ordering.
|
||||
*/
|
||||
async findAllPaginated(: string, opts: FindOrdersOpts = {}): Promise<PaginatedResult<Order>> {
|
||||
async findAllPaginated( opts: FindOrdersOpts = {}): Promise<PaginatedResult<Order>> {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
@@ -47,7 +47,7 @@ export class OrderRepository extends EntityRepository<Order> {
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const where: FilterQuery<Order> = { restaurant: { id: } };
|
||||
const where: FilterQuery<Order> = { };
|
||||
|
||||
// Filter by statuses
|
||||
if (statuses) {
|
||||
@@ -99,19 +99,7 @@ export class OrderRepository extends EntityRepository<Order> {
|
||||
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
|
||||
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
|
||||
const orderproductPairs: Array<{ orderId: string; productId: string }> = [];
|
||||
const orderproductPairs: Array<{ orderId: string; productId: bigint }> = [];
|
||||
for (const order of data) {
|
||||
for (const item of order.items.getItems()) {
|
||||
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
|
||||
for (const order of data) {
|
||||
@@ -164,7 +129,7 @@ export class OrderRepository extends EntityRepository<Order> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const product = item.product as any;
|
||||
// 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 { VerifyPaymentDto } from '../dto/verify-payment.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 { API_HEADER_SLUG } from 'src/common/constants';
|
||||
|
||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
|
||||
@@ -29,24 +29,9 @@ export class PaymentsController {
|
||||
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)
|
||||
@ApiBearerAuth()
|
||||
@@ -67,77 +52,67 @@ export class PaymentsController {
|
||||
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)
|
||||
@Permissions(Permission.MANAGE_PAYMENTS)
|
||||
@ApiBearerAuth()
|
||||
@Post('admin/payments/methods')
|
||||
@ApiOperation({ summary: 'Create a new restaurant payment method' })
|
||||
@ApiBody({ type: CreatePaymentMethodDto })
|
||||
createRestaurantPaymentMethod(, @Body() createPaymentMethodDto: CreatePaymentMethodDto) {
|
||||
return this.paymentMethodService.create(, createPaymentMethodDto);
|
||||
}
|
||||
// @UseGuards(AdminAuthGuard)
|
||||
// @Permissions(Permission.MANAGE_PAYMENTS)
|
||||
// @ApiBearerAuth()
|
||||
// @Post('admin/payments/methods')
|
||||
// @ApiOperation({ summary: 'Create a new restaurant payment method' })
|
||||
// @ApiBody({ type: CreatePaymentMethodDto })
|
||||
// createRestaurantPaymentMethod( @Body() createPaymentMethodDto: CreatePaymentMethodDto) {
|
||||
// return this.paymentMethodService.create( createPaymentMethodDto);
|
||||
// }
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_PAYMENTS)
|
||||
@ApiBearerAuth()
|
||||
@Get('admin/payments/methods/:paymentMethodId')
|
||||
@ApiOperation({ summary: 'Get restaurant payment method by ID' })
|
||||
@ApiParam({ name: 'paymentMethodId', type: 'string', description: 'Payment method ID' })
|
||||
findOneRestaurantPaymentMethod(@Param('paymentMethodId') paymentMethodId: string) {
|
||||
return this.paymentMethodService.findOne(paymentMethodId);
|
||||
}
|
||||
// @UseGuards(AdminAuthGuard)
|
||||
// @Permissions(Permission.MANAGE_PAYMENTS)
|
||||
// @ApiBearerAuth()
|
||||
// @Get('admin/payments/methods/:paymentMethodId')
|
||||
// @ApiOperation({ summary: 'Get restaurant payment method by ID' })
|
||||
// @ApiParam({ name: 'paymentMethodId', type: 'string', description: 'Payment method ID' })
|
||||
// findOneRestaurantPaymentMethod(@Param('paymentMethodId') paymentMethodId: string) {
|
||||
// return this.paymentMethodService.findOne(paymentMethodId);
|
||||
// }
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_PAYMENTS)
|
||||
@ApiBearerAuth()
|
||||
@Patch('admin/payments/methods/:paymentMethodId')
|
||||
@ApiOperation({ summary: 'Update a restaurant payment method' })
|
||||
@ApiParam({ name: 'paymentMethodId', type: 'string', description: 'Payment method ID' })
|
||||
@ApiBody({ type: UpdatePaymentMethodDto })
|
||||
updateRestaurantPaymentMethod(
|
||||
@Param('paymentMethodId') paymentMethodId: string,
|
||||
@Body() updatePaymentMethodDto: UpdatePaymentMethodDto,
|
||||
) {
|
||||
return this.paymentMethodService.update(paymentMethodId, updatePaymentMethodDto);
|
||||
}
|
||||
// @UseGuards(AdminAuthGuard)
|
||||
// @Permissions(Permission.MANAGE_PAYMENTS)
|
||||
// @ApiBearerAuth()
|
||||
// @Patch('admin/payments/methods/:paymentMethodId')
|
||||
// @ApiOperation({ summary: 'Update a restaurant payment method' })
|
||||
// @ApiParam({ name: 'paymentMethodId', type: 'string', description: 'Payment method ID' })
|
||||
// @ApiBody({ type: UpdatePaymentMethodDto })
|
||||
// updateRestaurantPaymentMethod(
|
||||
// @Param('paymentMethodId') paymentMethodId: string,
|
||||
// @Body() updatePaymentMethodDto: UpdatePaymentMethodDto,
|
||||
// ) {
|
||||
// return this.paymentMethodService.update(paymentMethodId, updatePaymentMethodDto);
|
||||
// }
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_PAYMENTS)
|
||||
@ApiBearerAuth()
|
||||
@Delete('admin/payments/methods/:paymentMethodId')
|
||||
@ApiOperation({ summary: 'Delete a restaurant payment method' })
|
||||
@ApiParam({ name: 'paymentMethodId', type: 'string', description: 'Payment method ID' })
|
||||
removeRestaurantPaymentMethod(@Param('paymentMethodId') paymentMethodId: string) {
|
||||
return this.paymentMethodService.remove(paymentMethodId);
|
||||
}
|
||||
// @UseGuards(AdminAuthGuard)
|
||||
// @Permissions(Permission.MANAGE_PAYMENTS)
|
||||
// @ApiBearerAuth()
|
||||
// @Delete('admin/payments/methods/:paymentMethodId')
|
||||
// @ApiOperation({ summary: 'Delete a restaurant payment method' })
|
||||
// @ApiParam({ name: 'paymentMethodId', type: 'string', description: 'Payment method ID' })
|
||||
// removeRestaurantPaymentMethod(@Param('paymentMethodId') paymentMethodId: string) {
|
||||
// return this.paymentMethodService.remove(paymentMethodId);
|
||||
// }
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_PAYMENTS)
|
||||
@ApiBearerAuth()
|
||||
@Post('admin/payments/verify-cash-method/:paymentId')
|
||||
@ApiOperation({ summary: 'Verify cash payment ' })
|
||||
@ApiParam({ name: 'paymentId', type: 'string', description: 'Payment ID' })
|
||||
verifyCashPayment(@Param('paymentId') paymentId: string) {
|
||||
return this.paymentsService.verifyCashPayment(paymentId);
|
||||
}
|
||||
// @UseGuards(AdminAuthGuard)
|
||||
// @Permissions(Permission.MANAGE_PAYMENTS)
|
||||
// @ApiBearerAuth()
|
||||
// @Post('admin/payments/verify-cash-method/:paymentId')
|
||||
// @ApiOperation({ summary: 'Verify cash payment ' })
|
||||
// @ApiParam({ name: 'paymentId', type: 'string', description: 'Payment ID' })
|
||||
// verifyCashPayment(@Param('paymentId') paymentId: string) {
|
||||
// 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;
|
||||
}
|
||||
@@ -32,139 +32,139 @@ export class PaymentListeners {
|
||||
return methodMap[method] || method;
|
||||
}
|
||||
|
||||
@OnEvent(paymentSucceedEvent.name)
|
||||
async handlePaymentSucceed(event: paymentSucceedEvent) {
|
||||
try {
|
||||
this.logger.log(
|
||||
`Payment paid event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||
);
|
||||
// get admnin os restuaraant that have order permissuins
|
||||
const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS);
|
||||
// const order=await
|
||||
const recipients = admins.map(admin => ({
|
||||
adminId: admin.id,
|
||||
}));
|
||||
await this.notificationService.sendNotification({
|
||||
: event.,
|
||||
message: {
|
||||
title: NotifTitleEnum.PAYMENT_SUCCESS,
|
||||
content: `پرداخت سفارش شماره ${event.orderNumber} با روش ${this.getPaymentMethodFarsi(event.paymentMethod)} و مبلغ ${event.total} تومان با موفقیت انجام شد`,
|
||||
sms: {
|
||||
templateId: this.orderCreatedSmsTemplateId,
|
||||
parameters: {
|
||||
orderNumber: event.orderNumber,
|
||||
// @OnEvent(paymentSucceedEvent.name)
|
||||
// async handlePaymentSucceed(event: paymentSucceedEvent) {
|
||||
// try {
|
||||
// this.logger.log(
|
||||
// `Payment paid event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||
// );
|
||||
// // get admnin os restuaraant that have order permissuins
|
||||
// const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS);
|
||||
// // const order=await
|
||||
// const recipients = admins.map(admin => ({
|
||||
// adminId: admin.id,
|
||||
// }));
|
||||
// await this.notificationService.sendNotification({
|
||||
// : event.,
|
||||
// message: {
|
||||
// title: NotifTitleEnum.PAYMENT_SUCCESS,
|
||||
// content: `پرداخت سفارش شماره ${event.orderNumber} با روش ${this.getPaymentMethodFarsi(event.paymentMethod)} و مبلغ ${event.total} تومان با موفقیت انجام شد`,
|
||||
// sms: {
|
||||
// templateId: this.orderCreatedSmsTemplateId,
|
||||
// parameters: {
|
||||
// orderNumber: event.orderNumber,
|
||||
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `پرداخت موفق`,
|
||||
content: `پرداخت سفارش شماره ${event.orderNumber} با روش ${this.getPaymentMethodFarsi(event.paymentMethod)} و مبلغ ${event.total} تومان با موفقیت انجام شد`,
|
||||
icon: ``,
|
||||
action: {
|
||||
type: NotifTitleEnum.PAYMENT_SUCCESS,
|
||||
url: `/`,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send notification for order created event: ${event.}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
// },
|
||||
// },
|
||||
// pushNotif: {
|
||||
// title: `پرداخت موفق`,
|
||||
// content: `پرداخت سفارش شماره ${event.orderNumber} با روش ${this.getPaymentMethodFarsi(event.paymentMethod)} و مبلغ ${event.total} تومان با موفقیت انجام شد`,
|
||||
// icon: ``,
|
||||
// action: {
|
||||
// type: NotifTitleEnum.PAYMENT_SUCCESS,
|
||||
// url: `/`,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// recipients,
|
||||
// metadata: {
|
||||
// priority: 1,
|
||||
// },
|
||||
// });
|
||||
// } catch (error) {
|
||||
// this.logger.error(
|
||||
// `Failed to send notification for order created event: ${event.}`,
|
||||
// error instanceof Error ? error.stack : String(error),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
@OnEvent(onlinePaymentSucceedEvent.name)
|
||||
async handleOnlinePaymentSucceed(event: onlinePaymentSucceedEvent) {
|
||||
try {
|
||||
// @OnEvent(onlinePaymentSucceedEvent.name)
|
||||
// async handleOnlinePaymentSucceed(event: onlinePaymentSucceedEvent) {
|
||||
// try {
|
||||
|
||||
this.logger.log(
|
||||
`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 recipients = admins.map(admin => ({
|
||||
adminId: admin.id,
|
||||
}));
|
||||
// this.logger.log(
|
||||
// `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 recipients = admins.map(admin => ({
|
||||
// adminId: admin.id,
|
||||
// }));
|
||||
|
||||
// admin notifs
|
||||
await this.notificationService.sendNotification({
|
||||
: event.,
|
||||
message: {
|
||||
title: NotifTitleEnum.ORDER_CREATED,
|
||||
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||
sms: {
|
||||
templateId: this.orderCreatedSmsTemplateId,
|
||||
parameters: {
|
||||
orderNumber: event.orderNumber,
|
||||
total: event.total.toString(),
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `سفارش جدید`,
|
||||
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||
icon: `/`,
|
||||
action: {
|
||||
type: NotifTitleEnum.ORDER_CREATED,
|
||||
url: `/`,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
// // admin notifs
|
||||
// await this.notificationService.sendNotification({
|
||||
// : event.,
|
||||
// message: {
|
||||
// title: NotifTitleEnum.ORDER_CREATED,
|
||||
// content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||
// sms: {
|
||||
// templateId: this.orderCreatedSmsTemplateId,
|
||||
// parameters: {
|
||||
// orderNumber: event.orderNumber,
|
||||
// total: event.total.toString(),
|
||||
// },
|
||||
// },
|
||||
// pushNotif: {
|
||||
// title: `سفارش جدید`,
|
||||
// content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||
// icon: `/`,
|
||||
// action: {
|
||||
// type: NotifTitleEnum.ORDER_CREATED,
|
||||
// url: `/`,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// recipients,
|
||||
// metadata: {
|
||||
// priority: 1,
|
||||
// },
|
||||
// });
|
||||
|
||||
const order = await this.orderService.findOne(event.orderId, event.);
|
||||
if (!order) {
|
||||
this.logger.error(
|
||||
`Order not found: ${event.orderId} for restaurant: ${event.}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const userRecipients = [
|
||||
{
|
||||
userId: order.user.id,
|
||||
},
|
||||
];
|
||||
// user notif
|
||||
await this.notificationService.sendNotification({
|
||||
: event.,
|
||||
message: {
|
||||
title: NotifTitleEnum.PAYMENT_SUCCESS,
|
||||
content: `پرداخت سفارش شماره ${order.orderNumber} با روش ${this.getPaymentMethodFarsi(order.paymentMethod.method)} و مبلغ ${order.total} تومان با موفقیت انجام شد`,
|
||||
sms: {
|
||||
templateId: this.paymentSucceedSmsTemplateId,
|
||||
parameters: {
|
||||
orderNumber: event.orderNumber,
|
||||
total: event.total.toString(),
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `پرداخت موفق`,
|
||||
content: `پرداخت سفارش شماره ${order.orderNumber} با روش ${this.getPaymentMethodFarsi(order.paymentMethod.method)} و مبلغ ${order.total} تومان با موفقیت انجام شد`,
|
||||
icon: `/`,
|
||||
action: {
|
||||
type: NotifTitleEnum.PAYMENT_SUCCESS,
|
||||
url: `/`,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients: userRecipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send notification for online payment succeed event: ${event.}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
// const order = await this.orderService.findOne(event.orderId, event.);
|
||||
// if (!order) {
|
||||
// this.logger.error(
|
||||
// `Order not found: ${event.orderId} for restaurant: ${event.}`,
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
// const userRecipients = [
|
||||
// {
|
||||
// userId: order.user.id,
|
||||
// },
|
||||
// ];
|
||||
// // user notif
|
||||
// await this.notificationService.sendNotification({
|
||||
// : event.,
|
||||
// message: {
|
||||
// title: NotifTitleEnum.PAYMENT_SUCCESS,
|
||||
// content: `پرداخت سفارش شماره ${order.orderNumber} با روش ${this.getPaymentMethodFarsi(order.paymentMethod.method)} و مبلغ ${order.total} تومان با موفقیت انجام شد`,
|
||||
// sms: {
|
||||
// templateId: this.paymentSucceedSmsTemplateId,
|
||||
// parameters: {
|
||||
// orderNumber: event.orderNumber,
|
||||
// total: event.total.toString(),
|
||||
// },
|
||||
// },
|
||||
// pushNotif: {
|
||||
// title: `پرداخت موفق`,
|
||||
// content: `پرداخت سفارش شماره ${order.orderNumber} با روش ${this.getPaymentMethodFarsi(order.paymentMethod.method)} و مبلغ ${order.total} تومان با موفقیت انجام شد`,
|
||||
// icon: `/`,
|
||||
// action: {
|
||||
// type: NotifTitleEnum.PAYMENT_SUCCESS,
|
||||
// url: `/`,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// recipients: userRecipients,
|
||||
// metadata: {
|
||||
// priority: 1,
|
||||
// },
|
||||
// });
|
||||
// } catch (error) {
|
||||
// this.logger.error(
|
||||
// `Failed to send notification for online payment succeed event: ${event.}`,
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -5,15 +5,13 @@ import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { Order } from '../../order/entities/order.entity';
|
||||
import { Logger } from '@nestjs/common';
|
||||
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 { ChartPeriodEnum, PaymentChartDto } from '../dto/payment-chart.dto';
|
||||
import { PaymentMessage, OrderMessage } from 'src/common/enums/message.enum';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { onlinePaymentSucceedEvent, paymentSucceedEvent } from '../events/payment.events';
|
||||
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/user/interface/wallet';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
@@ -24,419 +22,299 @@ export class PaymentsService {
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) { }
|
||||
|
||||
async payOrder(orderId: string): Promise<{ paymentUrl: string | null }> {
|
||||
const ctx = await this.loadAndValidateOrder(orderId);
|
||||
|
||||
// Idempotency: avoid creating/charging again for already-paid orders
|
||||
if (ctx.order.status === OrderStatus.PAID) {
|
||||
return { paymentUrl: null };
|
||||
}
|
||||
|
||||
switch (ctx.method) {
|
||||
case PaymentMethodEnum.Cash:
|
||||
await this.handleCashPayment(ctx);
|
||||
return { paymentUrl: null };
|
||||
|
||||
case PaymentMethodEnum.Wallet:
|
||||
await this.handleWalletPayment(ctx);
|
||||
return { paymentUrl: null };
|
||||
|
||||
case PaymentMethodEnum.Online:
|
||||
return this.handleOnlinePayment(ctx);
|
||||
|
||||
default:
|
||||
throw new BadRequestException(PaymentMessage.UNSUPPORTED_PAYMENT_METHOD);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAndValidateOrder(orderId: string): Promise<OrderPaymentContext> {
|
||||
const order = await this.em.findOne(
|
||||
Order,
|
||||
{ id: orderId },
|
||||
{ populate: ['user', 'restaurant', 'paymentMethod', 'paymentMethod.restaurant'] },
|
||||
);
|
||||
|
||||
if (!order) {
|
||||
throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (order.total <= 0) {
|
||||
throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO);
|
||||
}
|
||||
|
||||
const pm = order.paymentMethod;
|
||||
if (!pm) {
|
||||
throw new NotFoundException(PaymentMessage.PAYMENT_METHOD_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (pm.method === PaymentMethodEnum.Online) {
|
||||
if (!pm.gateway) throw new BadRequestException(PaymentMessage.GATEWAY_REQUIRED);
|
||||
if (!pm.merchantId) throw new BadRequestException(PaymentMessage.MERCHANT_ID_REQUIRED);
|
||||
if (!pm.restaurant?.domain) throw new BadRequestException(PaymentMessage.RESTAURANT_DOMAIN_REQUIRED);
|
||||
}
|
||||
|
||||
return {
|
||||
order,
|
||||
amount: order.total,
|
||||
method: pm.method,
|
||||
gateway: pm.gateway ?? null,
|
||||
merchantId: pm.merchantId ?? null,
|
||||
restaurantDomain: pm.restaurant.domain ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleCashPayment(ctx: OrderPaymentContext): Promise<void> {
|
||||
await this.getOrCreateLatestPendingPayment(ctx.order.id, {
|
||||
amount: ctx.amount,
|
||||
method: PaymentMethodEnum.Cash,
|
||||
gateway: null,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO clean this code and refactor it
|
||||
private async handleWalletPayment(ctx: OrderPaymentContext): Promise<void> {
|
||||
await this.em.transactional(async em => {
|
||||
const order = await em.findOne(Order, { id: ctx.order.id }, { populate: ['user', 'restaurant'] });
|
||||
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||
|
||||
if (order.status === OrderStatus.PAID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const walletTransaction = await em.findOne(WalletTransaction, {
|
||||
user: { id: order.user.id },
|
||||
restaurant: { id: order.restaurant.id },
|
||||
}, {
|
||||
orderBy: { createdAt: 'DESC' }
|
||||
});
|
||||
|
||||
if (!walletTransaction) {
|
||||
throw new NotFoundException('User wallet not found');
|
||||
}
|
||||
|
||||
if (walletTransaction.balance < ctx.amount) {
|
||||
throw new BadRequestException('Insufficient wallet balance');
|
||||
}
|
||||
|
||||
const newBalance = walletTransaction.balance - ctx.amount;
|
||||
|
||||
|
||||
const payment = await this.getOrCreateLatestPendingPayment(order.id, {
|
||||
em,
|
||||
amount: ctx.amount,
|
||||
method: PaymentMethodEnum.Wallet,
|
||||
gateway: null,
|
||||
});
|
||||
|
||||
const newWalletTransaction = em.create(WalletTransaction, {
|
||||
user: order.user,
|
||||
restaurant: order.restaurant,
|
||||
amount: ctx.amount,
|
||||
type: WalletTransactionType.DEBIT,
|
||||
reason: WalletTransactionReason.ORDER_PAYMENT,
|
||||
balance: newBalance,
|
||||
});
|
||||
|
||||
payment.status = PaymentStatusEnum.Paid;
|
||||
payment.paidAt = new Date();
|
||||
order.status = OrderStatus.PAID;
|
||||
|
||||
em.persist([payment, order, newWalletTransaction]);
|
||||
await em.flush();
|
||||
});
|
||||
this.eventEmitter.emit(
|
||||
paymentSucceedEvent.name,
|
||||
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 }> {
|
||||
const gateway = this.gatewayManager.get(ctx.gateway!);
|
||||
|
||||
const payment = await this.getOrCreateLatestPendingPayment(ctx.order.id, {
|
||||
amount: ctx.amount,
|
||||
method: PaymentMethodEnum.Online,
|
||||
gateway: ctx.gateway!,
|
||||
});
|
||||
|
||||
// If we already requested a gateway transaction, just return the same URL (idempotent)
|
||||
if (payment.transactionId) {
|
||||
return { paymentUrl: gateway.getPaymentUrl(payment.transactionId) };
|
||||
}
|
||||
|
||||
const { transactionId } = await gateway.requestPayment({
|
||||
amount: ctx.amount,
|
||||
orderId: ctx.order.id,
|
||||
merchantId: ctx.merchantId!,
|
||||
domain: ctx.restaurantDomain!,
|
||||
});
|
||||
|
||||
payment.gateway = ctx.gateway;
|
||||
payment.transactionId = transactionId;
|
||||
payment.status = PaymentStatusEnum.Pending;
|
||||
|
||||
await this.em.persistAndFlush(payment);
|
||||
|
||||
return {
|
||||
paymentUrl: gateway.getPaymentUrl(transactionId),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyOnlinePayment(transactionId: string, orderId: string): Promise<Payment> {
|
||||
const payment = await this.em.transactional(async em => {
|
||||
const payment = await em.findOne(
|
||||
Payment,
|
||||
{ transactionId, order: { id: orderId } },
|
||||
{ populate: ['order', 'order.paymentMethod'] },
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (payment.status === PaymentStatusEnum.Paid) {
|
||||
return payment;
|
||||
}
|
||||
|
||||
const pm = payment.order.paymentMethod;
|
||||
if (!pm?.merchantId || !payment.gateway) {
|
||||
throw new BadRequestException(PaymentMessage.INVALID_PAYMENT_CONFIGURATION);
|
||||
}
|
||||
|
||||
const gateway = this.gatewayManager.get(payment.gateway);
|
||||
|
||||
const result = await gateway.verifyPayment({
|
||||
merchantId: pm.merchantId,
|
||||
amount: payment.amount,
|
||||
transactionId: payment.transactionId!,
|
||||
});
|
||||
|
||||
payment.verifyResponse = result.raw;
|
||||
|
||||
if (!result.success) {
|
||||
this.failPayment(payment);
|
||||
return payment;
|
||||
}
|
||||
|
||||
this.markPaid(payment, result.referenceId);
|
||||
if (payment.order.status === OrderStatus.PENDING_PAYMENT) {
|
||||
payment.order.status = OrderStatus.PAID;
|
||||
}
|
||||
|
||||
await em.flush();
|
||||
return payment;
|
||||
});
|
||||
this.eventEmitter.emit(
|
||||
onlinePaymentSucceedEvent.name,
|
||||
new onlinePaymentSucceedEvent(payment.id, payment.order.id, payment.order.restaurant.id, String(payment.order?.orderNumber) || '', payment.amount),
|
||||
);
|
||||
return payment;
|
||||
}
|
||||
|
||||
findAllPaymentsBy(: string, userId: string) {
|
||||
return this.em.find(
|
||||
Payment,
|
||||
{ order: { restaurant: { id: }, user: { id: userId } } },
|
||||
{ populate: ['order', 'order.paymentMethod'] },
|
||||
);
|
||||
}
|
||||
|
||||
async verifyCashPayment(id: string): Promise<Payment> {
|
||||
const payment = await this.em.transactional(async em => {
|
||||
const payment = await em.findOne(Payment, id, { populate: ['order'] });
|
||||
if (!payment) {
|
||||
throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND);
|
||||
}
|
||||
if (payment.method !== PaymentMethodEnum.Cash) {
|
||||
throw new BadRequestException(PaymentMessage.PAYMENT_METHOD_NOT_CASH);
|
||||
}
|
||||
if (payment.status === PaymentStatusEnum.Paid) {
|
||||
throw new BadRequestException(PaymentMessage.PAYMENT_ALREADY_PAID);
|
||||
}
|
||||
if (payment.order.status === OrderStatus.PENDING_PAYMENT) {
|
||||
payment.order.status = OrderStatus.PAID;
|
||||
}
|
||||
payment.status = PaymentStatusEnum.Paid;
|
||||
payment.paidAt = new Date();
|
||||
em.persist(payment);
|
||||
em.persist(payment.order);
|
||||
await em.flush();
|
||||
return payment;
|
||||
});
|
||||
return payment;
|
||||
}
|
||||
|
||||
private markPaid(payment: Payment, referenceId: string) {
|
||||
payment.status = PaymentStatusEnum.Paid;
|
||||
payment.paidAt = new Date();
|
||||
payment.referenceId = referenceId;
|
||||
}
|
||||
|
||||
private failPayment(payment: Payment) {
|
||||
payment.status = PaymentStatusEnum.Failed;
|
||||
payment.failedAt = new Date();
|
||||
payment.order.status = OrderStatus.CANCELED;
|
||||
}
|
||||
|
||||
|
||||
private async getOrCreateLatestPendingPayment(
|
||||
orderId: string,
|
||||
params: {
|
||||
amount: number;
|
||||
method: PaymentMethodEnum;
|
||||
gateway: PaymentGatewayEnum | null;
|
||||
em?: EntityManager;
|
||||
},
|
||||
): Promise<Payment> {
|
||||
const em = params.em ?? this.em;
|
||||
|
||||
const existing = await em.findOne(
|
||||
Payment,
|
||||
{ order: { id: orderId }, status: PaymentStatusEnum.Pending },
|
||||
{ orderBy: { createdAt: 'DESC' } },
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
if (existing.method !== params.method) {
|
||||
throw new BadRequestException(PaymentMessage.EXISTING_PENDING_PAYMENT_MISMATCH);
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
const orderRef = em.getReference(Order, orderId);
|
||||
const payment = em.create(Payment, {
|
||||
amount: params.amount,
|
||||
order: orderRef,
|
||||
method: params.method,
|
||||
gateway: params.gateway,
|
||||
transactionId: null,
|
||||
status: PaymentStatusEnum.Pending,
|
||||
});
|
||||
|
||||
em.persist(payment);
|
||||
await em.flush();
|
||||
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;
|
||||
}
|
||||
// async payOrder(orderId: string): Promise<{ paymentUrl: string | null }> {
|
||||
// const ctx = await this.loadAndValidateOrder(orderId);
|
||||
|
||||
// // Idempotency: avoid creating/charging again for already-paid orders
|
||||
// if (ctx.order.status === OrderStatus.PAID) {
|
||||
// return { paymentUrl: null };
|
||||
// }
|
||||
|
||||
// switch (ctx.method) {
|
||||
// case PaymentMethodEnum.Cash:
|
||||
// await this.handleCashPayment(ctx);
|
||||
// return { paymentUrl: null };
|
||||
|
||||
// case PaymentMethodEnum.Wallet:
|
||||
// await this.handleWalletPayment(ctx);
|
||||
// return { paymentUrl: null };
|
||||
|
||||
// case PaymentMethodEnum.Online:
|
||||
// return this.handleOnlinePayment(ctx);
|
||||
|
||||
// default:
|
||||
// throw new BadRequestException(PaymentMessage.UNSUPPORTED_PAYMENT_METHOD);
|
||||
// }
|
||||
// }
|
||||
|
||||
// private async loadAndValidateOrder(orderId: string): Promise<OrderPaymentContext> {
|
||||
// const order = await this.em.findOne(
|
||||
// Order,
|
||||
// { id: orderId },
|
||||
// { populate: ['user', 'restaurant', 'paymentMethod', 'paymentMethod.restaurant'] },
|
||||
// );
|
||||
|
||||
// if (!order) {
|
||||
// throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||
// }
|
||||
|
||||
// if (order.total <= 0) {
|
||||
// throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO);
|
||||
// }
|
||||
|
||||
// const pm = order.paymentMethod;
|
||||
// if (!pm) {
|
||||
// throw new NotFoundException(PaymentMessage.PAYMENT_METHOD_NOT_FOUND);
|
||||
// }
|
||||
|
||||
// if (pm.method === PaymentMethodEnum.Online) {
|
||||
// if (!pm.gateway) throw new BadRequestException(PaymentMessage.GATEWAY_REQUIRED);
|
||||
// if (!pm.merchantId) throw new BadRequestException(PaymentMessage.MERCHANT_ID_REQUIRED);
|
||||
// if (!pm.restaurant?.domain) throw new BadRequestException(PaymentMessage.RESTAURANT_DOMAIN_REQUIRED);
|
||||
// }
|
||||
|
||||
// return {
|
||||
// order,
|
||||
// amount: order.total,
|
||||
// method: pm.method,
|
||||
// gateway: pm.gateway ?? null,
|
||||
// merchantId: pm.merchantId ?? null,
|
||||
// restaurantDomain: pm.restaurant.domain ?? null,
|
||||
// };
|
||||
// }
|
||||
|
||||
// private async handleCashPayment(ctx: OrderPaymentContext): Promise<void> {
|
||||
// await this.getOrCreateLatestPendingPayment(ctx.order.id, {
|
||||
// amount: ctx.amount,
|
||||
// method: PaymentMethodEnum.Cash,
|
||||
// gateway: null,
|
||||
// });
|
||||
// }
|
||||
|
||||
|
||||
// private async handleWalletPayment(ctx: OrderPaymentContext): Promise<void> {
|
||||
// await this.em.transactional(async em => {
|
||||
// const order = await em.findOne(Order, { id: ctx.order.id }, { populate: ['user', 'restaurant'] });
|
||||
// if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||
|
||||
// if (order.status === OrderStatus.PAID) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const walletTransaction = await em.findOne(WalletTransaction, {
|
||||
// user: { id: order.user.id },
|
||||
// restaurant: { id: order.restaurant.id },
|
||||
// }, {
|
||||
// orderBy: { createdAt: 'DESC' }
|
||||
// });
|
||||
|
||||
// if (!walletTransaction) {
|
||||
// throw new NotFoundException('User wallet not found');
|
||||
// }
|
||||
|
||||
// if (walletTransaction.balance < ctx.amount) {
|
||||
// throw new BadRequestException('Insufficient wallet balance');
|
||||
// }
|
||||
|
||||
// const newBalance = walletTransaction.balance - ctx.amount;
|
||||
|
||||
|
||||
// const payment = await this.getOrCreateLatestPendingPayment(order.id, {
|
||||
// em,
|
||||
// amount: ctx.amount,
|
||||
// method: PaymentMethodEnum.Wallet,
|
||||
// gateway: null,
|
||||
// });
|
||||
|
||||
// const newWalletTransaction = em.create(WalletTransaction, {
|
||||
// user: order.user,
|
||||
// restaurant: order.restaurant,
|
||||
// amount: ctx.amount,
|
||||
// type: WalletTransactionType.DEBIT,
|
||||
// reason: WalletTransactionReason.ORDER_PAYMENT,
|
||||
// balance: newBalance,
|
||||
// });
|
||||
|
||||
// payment.status = PaymentStatusEnum.Paid;
|
||||
// payment.paidAt = new Date();
|
||||
// order.status = OrderStatus.PAID;
|
||||
|
||||
// em.persist([payment, order, newWalletTransaction]);
|
||||
// await em.flush();
|
||||
// });
|
||||
// this.eventEmitter.emit(
|
||||
// paymentSucceedEvent.name,
|
||||
// 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 }> {
|
||||
// const gateway = this.gatewayManager.get(ctx.gateway!);
|
||||
|
||||
// const payment = await this.getOrCreateLatestPendingPayment(ctx.order.id, {
|
||||
// amount: ctx.amount,
|
||||
// method: PaymentMethodEnum.Online,
|
||||
// gateway: ctx.gateway!,
|
||||
// });
|
||||
|
||||
// // If we already requested a gateway transaction, just return the same URL (idempotent)
|
||||
// if (payment.transactionId) {
|
||||
// return { paymentUrl: gateway.getPaymentUrl(payment.transactionId) };
|
||||
// }
|
||||
|
||||
// const { transactionId } = await gateway.requestPayment({
|
||||
// amount: ctx.amount,
|
||||
// orderId: ctx.order.id,
|
||||
// merchantId: ctx.merchantId!,
|
||||
// domain: ctx.restaurantDomain!,
|
||||
// });
|
||||
|
||||
// payment.gateway = ctx.gateway;
|
||||
// payment.transactionId = transactionId;
|
||||
// payment.status = PaymentStatusEnum.Pending;
|
||||
|
||||
// await this.em.persistAndFlush(payment);
|
||||
|
||||
// return {
|
||||
// paymentUrl: gateway.getPaymentUrl(transactionId),
|
||||
// };
|
||||
// }
|
||||
|
||||
// async verifyOnlinePayment(transactionId: string, orderId: string): Promise<Payment> {
|
||||
// const payment = await this.em.transactional(async em => {
|
||||
// const payment = await em.findOne(
|
||||
// Payment,
|
||||
// { transactionId, order: { id: orderId } },
|
||||
// { populate: ['order', 'order.paymentMethod'] },
|
||||
// );
|
||||
|
||||
// if (!payment) {
|
||||
// throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND);
|
||||
// }
|
||||
|
||||
// if (payment.status === PaymentStatusEnum.Paid) {
|
||||
// return payment;
|
||||
// }
|
||||
|
||||
// const pm = payment.order.paymentMethod;
|
||||
// if (!pm?.merchantId || !payment.gateway) {
|
||||
// throw new BadRequestException(PaymentMessage.INVALID_PAYMENT_CONFIGURATION);
|
||||
// }
|
||||
|
||||
// const gateway = this.gatewayManager.get(payment.gateway);
|
||||
|
||||
// const result = await gateway.verifyPayment({
|
||||
// merchantId: pm.merchantId,
|
||||
// amount: payment.amount,
|
||||
// transactionId: payment.transactionId!,
|
||||
// });
|
||||
|
||||
// payment.verifyResponse = result.raw;
|
||||
|
||||
// if (!result.success) {
|
||||
// this.failPayment(payment);
|
||||
// return payment;
|
||||
// }
|
||||
|
||||
// this.markPaid(payment, result.referenceId);
|
||||
// if (payment.order.status === OrderStatus.PENDING_PAYMENT) {
|
||||
// payment.order.status = OrderStatus.PAID;
|
||||
// }
|
||||
|
||||
// await em.flush();
|
||||
// return payment;
|
||||
// });
|
||||
// this.eventEmitter.emit(
|
||||
// onlinePaymentSucceedEvent.name,
|
||||
// new onlinePaymentSucceedEvent(payment.id, payment.order.id, payment.order.restaurant.id, String(payment.order?.orderNumber) || '', payment.amount),
|
||||
// );
|
||||
// return payment;
|
||||
// }
|
||||
|
||||
// findAllPaymentsBy(: string, userId: string) {
|
||||
// return this.em.find(
|
||||
// Payment,
|
||||
// { order: { restaurant: { id: }, user: { id: userId } } },
|
||||
// { populate: ['order', 'order.paymentMethod'] },
|
||||
// );
|
||||
// }
|
||||
|
||||
// async verifyCashPayment(id: string): Promise<Payment> {
|
||||
// const payment = await this.em.transactional(async em => {
|
||||
// const payment = await em.findOne(Payment, id, { populate: ['order'] });
|
||||
// if (!payment) {
|
||||
// throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND);
|
||||
// }
|
||||
// if (payment.method !== PaymentMethodEnum.Cash) {
|
||||
// throw new BadRequestException(PaymentMessage.PAYMENT_METHOD_NOT_CASH);
|
||||
// }
|
||||
// if (payment.status === PaymentStatusEnum.Paid) {
|
||||
// throw new BadRequestException(PaymentMessage.PAYMENT_ALREADY_PAID);
|
||||
// }
|
||||
// if (payment.order.status === OrderStatus.PENDING_PAYMENT) {
|
||||
// payment.order.status = OrderStatus.PAID;
|
||||
// }
|
||||
// payment.status = PaymentStatusEnum.Paid;
|
||||
// payment.paidAt = new Date();
|
||||
// em.persist(payment);
|
||||
// em.persist(payment.order);
|
||||
// await em.flush();
|
||||
// return payment;
|
||||
// });
|
||||
// return payment;
|
||||
// }
|
||||
|
||||
// private markPaid(payment: Payment, referenceId: string) {
|
||||
// payment.status = PaymentStatusEnum.Paid;
|
||||
// payment.paidAt = new Date();
|
||||
// payment.referenceId = referenceId;
|
||||
// }
|
||||
|
||||
// private failPayment(payment: Payment) {
|
||||
// payment.status = PaymentStatusEnum.Failed;
|
||||
// payment.failedAt = new Date();
|
||||
// payment.order.status = OrderStatus.CANCELED;
|
||||
// }
|
||||
|
||||
|
||||
// private async getOrCreateLatestPendingPayment(
|
||||
// orderId: string,
|
||||
// params: {
|
||||
// amount: number;
|
||||
// method: PaymentMethodEnum;
|
||||
// gateway: PaymentGatewayEnum | null;
|
||||
// em?: EntityManager;
|
||||
// },
|
||||
// ): Promise<Payment> {
|
||||
// const em = params.em ?? this.em;
|
||||
|
||||
// const existing = await em.findOne(
|
||||
// Payment,
|
||||
// { order: { id: orderId }, status: PaymentStatusEnum.Pending },
|
||||
// { orderBy: { createdAt: 'DESC' } },
|
||||
// );
|
||||
|
||||
// if (existing) {
|
||||
// if (existing.method !== params.method) {
|
||||
// throw new BadRequestException(PaymentMessage.EXISTING_PENDING_PAYMENT_MISMATCH);
|
||||
// }
|
||||
|
||||
// return existing;
|
||||
// }
|
||||
|
||||
// const orderRef = em.getReference(Order, orderId);
|
||||
// const payment = em.create(Payment, {
|
||||
// amount: params.amount,
|
||||
// order: orderRef,
|
||||
// method: params.method,
|
||||
// gateway: params.gateway,
|
||||
// transactionId: null,
|
||||
// status: PaymentStatusEnum.Pending,
|
||||
// });
|
||||
|
||||
// em.persist(payment);
|
||||
// await em.flush();
|
||||
// return payment;
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ export class UsersController {
|
||||
|
||||
@ApiBody({ type: UpdateUserDto })
|
||||
@Patch('public/user/update')
|
||||
async updateUser(@UserId() userId: string, , @Body() dto: UpdateUserDto) {
|
||||
const user = await this.userService.updateUser(userId, , dto);
|
||||
async updateUser(@UserId() userId: string, @Body() dto: UpdateUserDto) {
|
||||
const user = await this.userService.updateUser(userId, dto);
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user