notification module refactor
This commit is contained in:
@@ -1,24 +1,24 @@
|
|||||||
import { IsNotEmpty, IsEnum } from 'class-validator';
|
import { IsNotEmpty, IsEnum } from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { NotificationTitle } from '../entities/notification.entity';
|
import { NotificationTitleEnum } from '../interfaces/notification.interface';
|
||||||
import { NotificationType } from '../interfaces/notification.interface';
|
import { NotificationType } from '../interfaces/notification.interface';
|
||||||
|
|
||||||
export class CreatePreferenceDto {
|
export class CreatePreferenceDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Notification type (SMS, PUSH, BOTH, or NONE)',
|
description: 'Notification type (SMS, PUSH, BOTH, or NONE)',
|
||||||
enum: NotificationType,
|
enum: NotificationType,
|
||||||
example: NotificationType.SMS
|
example: NotificationType.SMS,
|
||||||
})
|
})
|
||||||
@IsEnum(NotificationType)
|
@IsEnum(NotificationType)
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
notificationType!: NotificationType;
|
notificationType!: NotificationType;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Notification title/type (e.g., order.created, review.created)',
|
description: 'Notification title/type (e.g., order.created, review.created)',
|
||||||
enum: NotificationTitle,
|
enum: NotificationTitleEnum,
|
||||||
example: NotificationTitle.ORDER_CREATED
|
example: NotificationTitleEnum.ORDER_CREATED,
|
||||||
})
|
})
|
||||||
@IsEnum(NotificationTitle)
|
@IsEnum(NotificationTitleEnum)
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
title!: NotificationTitle;
|
title!: NotificationTitleEnum;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Entity, Property, ManyToOne, Unique, Enum } from '@mikro-orm/core';
|
import { Entity, Property, ManyToOne, Unique, Enum } from '@mikro-orm/core';
|
||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
import { NotificationTitle } from './notification.entity';
|
import { NotificationTitleEnum } from '../interfaces/notification.interface';
|
||||||
import { NotificationType } from '../interfaces/notification.interface';
|
import { NotificationType } from '../interfaces/notification.interface';
|
||||||
|
|
||||||
@Entity({ tableName: 'notification_preferences' })
|
@Entity({ tableName: 'notification_preferences' })
|
||||||
@@ -11,7 +11,7 @@ export class NotificationPreference extends BaseEntity {
|
|||||||
restaurant!: Restaurant;
|
restaurant!: Restaurant;
|
||||||
|
|
||||||
@Property()
|
@Property()
|
||||||
title!: NotificationTitle;
|
title!: NotificationTitleEnum;
|
||||||
|
|
||||||
@Enum(() => NotificationType)
|
@Enum(() => NotificationType)
|
||||||
notificationType!: NotificationType; //sms, push, email
|
notificationType!: NotificationType; //sms, push, email
|
||||||
|
|||||||
@@ -2,12 +2,7 @@ import { Entity, Property, ManyToOne, Enum } from '@mikro-orm/core';
|
|||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { User } from '../../users/entities/user.entity';
|
||||||
|
import { NotificationTitleEnum } from '../interfaces/notification.interface';
|
||||||
export enum NotificationTitle {
|
|
||||||
ORDER_CREATED = 'order.created',
|
|
||||||
REVIEW_CREATED = 'review.created',
|
|
||||||
ORDER_STATUS_CHANGED = 'order.status.changed',
|
|
||||||
}
|
|
||||||
|
|
||||||
@Entity({ tableName: 'notifications' })
|
@Entity({ tableName: 'notifications' })
|
||||||
export class Notification extends BaseEntity {
|
export class Notification extends BaseEntity {
|
||||||
@@ -17,14 +12,14 @@ export class Notification extends BaseEntity {
|
|||||||
@ManyToOne(() => User, { nullable: true })
|
@ManyToOne(() => User, { nullable: true })
|
||||||
user?: User;
|
user?: User;
|
||||||
|
|
||||||
@Enum(() => NotificationTitle)
|
@Enum(() => NotificationTitleEnum)
|
||||||
title!: NotificationTitle;
|
title!: NotificationTitleEnum;
|
||||||
|
|
||||||
@Property()
|
@Property()
|
||||||
content!: string;
|
content!: string;
|
||||||
|
|
||||||
@Property({ nullable: true, unique: true })
|
// @Property({ nullable: true, unique: true })
|
||||||
idempotencyKey?: string;
|
// idempotencyKey?: string;
|
||||||
|
|
||||||
@Property({ nullable: true })
|
@Property({ nullable: true })
|
||||||
sentAt?: Date;
|
sentAt?: Date;
|
||||||
|
|||||||
@@ -2,8 +2,17 @@ export class OrderCreatedEvent {
|
|||||||
constructor(
|
constructor(
|
||||||
public readonly restaurantId: string,
|
public readonly restaurantId: string,
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly orderId: string,
|
public readonly orderNumber: string,
|
||||||
public readonly orderData: any,
|
public readonly totalAmount: number,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OrderPaymentSuccessEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly restaurantId: string,
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly orderNumber: string,
|
||||||
|
public readonly totalAmount: number,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { NotificationType } from './notification.interface';
|
import type { NotificationType } from './notification.interface';
|
||||||
import type { NotificationTitle } from '../entities/notification.entity';
|
import type { NotificationTitleEnum } from './notification.interface';
|
||||||
|
|
||||||
export interface NotificationQueueJob {
|
export interface NotificationQueueJob {
|
||||||
restaurantId: string;
|
restaurantId: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
title: NotificationTitle;
|
title: NotificationTitleEnum;
|
||||||
content: string;
|
content: string;
|
||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
notificationId?: string; // For retries
|
notificationId?: string; // For retries
|
||||||
|
|||||||
@@ -1,6 +1,44 @@
|
|||||||
|
export enum NotificationTitleEnum {
|
||||||
|
ORDER_CREATED = 'order.created',
|
||||||
|
PAYMENT_SUCCESS = 'payment.success',
|
||||||
|
REVIEW_CREATED = 'review.created',
|
||||||
|
ORDER_STATUS_CHANGED = 'order.status.changed',
|
||||||
|
}
|
||||||
|
|
||||||
export enum NotificationType {
|
export enum NotificationType {
|
||||||
NONE = 'none',
|
NONE = 'none',
|
||||||
SMS = 'sms',
|
SMS = 'sms',
|
||||||
PUSH = 'push',
|
PUSH = 'push',
|
||||||
Both = 'both',
|
Both = 'both',
|
||||||
}
|
}
|
||||||
|
interface INotifySmsPayload {
|
||||||
|
[key: string]: string | number | Date;
|
||||||
|
}
|
||||||
|
interface INotifySms {
|
||||||
|
phone: string;
|
||||||
|
subject: NotificationTitleEnum;
|
||||||
|
}
|
||||||
|
// Sub intefaces
|
||||||
|
// 1. Order Created Sms Notify Payload
|
||||||
|
interface IOrderCreatedSmsNotifyPayload extends INotifySmsPayload {
|
||||||
|
orderNumber: string;
|
||||||
|
orderAmount: number;
|
||||||
|
orderDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IOrderCreatedSmsNotify extends INotifySms {
|
||||||
|
subject: NotificationTitleEnum.ORDER_CREATED;
|
||||||
|
payload: IOrderCreatedSmsNotifyPayload;
|
||||||
|
}
|
||||||
|
// 2. Payment Success
|
||||||
|
interface IPaymentSuccessSmsNotifyPayload extends INotifySmsPayload {
|
||||||
|
amount: number;
|
||||||
|
date: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPaymentSuccessSmsNotify extends INotifySms {
|
||||||
|
subject: NotificationTitleEnum.PAYMENT_SUCCESS;
|
||||||
|
payload: IPaymentSuccessSmsNotifyPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ISmsNotifyPayload = IOrderCreatedSmsNotify | IPaymentSuccessSmsNotify;
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { NotificationService } from '../services/notification.service';
|
import { NotificationService } from '../services/notification.service';
|
||||||
import { NotificationTitle } from '../entities/notification.entity';
|
import { NotificationTitleEnum } from '../interfaces/notification.interface';
|
||||||
import { OrderCreatedEvent, ReviewCreatedEvent, OrderStatusChangedEvent } from '../events/notification.events';
|
import {
|
||||||
|
OrderCreatedEvent,
|
||||||
|
ReviewCreatedEvent,
|
||||||
|
OrderStatusChangedEvent,
|
||||||
|
OrderPaymentSuccessEvent,
|
||||||
|
} from '../events/notification.events';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationListeners {
|
export class NotificationListeners {
|
||||||
@@ -13,17 +18,35 @@ export class NotificationListeners {
|
|||||||
@OnEvent('order.created')
|
@OnEvent('order.created')
|
||||||
async handleOrderCreated(event: OrderCreatedEvent) {
|
async handleOrderCreated(event: OrderCreatedEvent) {
|
||||||
try {
|
try {
|
||||||
this.logger.log(`Order created event received: ${event.orderId}`);
|
this.logger.log(`Order created event received: ${event.orderNumber}`);
|
||||||
|
|
||||||
await this.notificationService.sendNotification({
|
await this.notificationService.sendNotification({
|
||||||
restaurantId: event.restaurantId,
|
restaurantId: event.restaurantId,
|
||||||
userId: event.userId,
|
userId: event.userId,
|
||||||
title: NotificationTitle.ORDER_CREATED,
|
title: NotificationTitleEnum.ORDER_CREATED,
|
||||||
content: `Your order #${event.orderId} has been confirmed`,
|
content: `Your order #${event.orderNumber} has created successfully`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to send notification for order.created event: ${event.orderId}`,
|
`Failed to send notification for order.created event: ${event.orderNumber}`,
|
||||||
|
error instanceof Error ? error.stack : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@OnEvent('order.payment.success')
|
||||||
|
async handleOrderPaymentSuccess(event: OrderPaymentSuccessEvent) {
|
||||||
|
try {
|
||||||
|
this.logger.log(`Order payment success event received: ${event.orderNumber}`);
|
||||||
|
|
||||||
|
await this.notificationService.sendNotification({
|
||||||
|
restaurantId: event.restaurantId,
|
||||||
|
userId: event.userId,
|
||||||
|
title: NotificationTitleEnum.ORDER_CREATED,
|
||||||
|
content: `Your Payement for order #${event.orderNumber} has been successful Paid ${event.totalAmount}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to send notification for order.created event: ${event.orderNumber}`,
|
||||||
error instanceof Error ? error.stack : String(error),
|
error instanceof Error ? error.stack : String(error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -37,7 +60,7 @@ export class NotificationListeners {
|
|||||||
await this.notificationService.sendNotification({
|
await this.notificationService.sendNotification({
|
||||||
restaurantId: event.restaurantId,
|
restaurantId: event.restaurantId,
|
||||||
userId: event.userId,
|
userId: event.userId,
|
||||||
title: NotificationTitle.REVIEW_CREATED,
|
title: NotificationTitleEnum.REVIEW_CREATED,
|
||||||
content: 'Thank you for your review!',
|
content: 'Thank you for your review!',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -56,7 +79,7 @@ export class NotificationListeners {
|
|||||||
await this.notificationService.sendNotification({
|
await this.notificationService.sendNotification({
|
||||||
restaurantId: event.restaurantId,
|
restaurantId: event.restaurantId,
|
||||||
userId: event.userId,
|
userId: event.userId,
|
||||||
title: NotificationTitle.ORDER_STATUS_CHANGED,
|
title: NotificationTitleEnum.ORDER_STATUS_CHANGED,
|
||||||
content: `Your order #${event.orderId} is now ${event.newStatus}`,
|
content: `Your order #${event.orderId} is now ${event.newStatus}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { NotificationService } from './services/notification.service';
|
|||||||
import { NotificationPreferenceService } from './services/notification-preference.service';
|
import { NotificationPreferenceService } from './services/notification-preference.service';
|
||||||
import { NotificationQueueService } from './services/notification-queue.service';
|
import { NotificationQueueService } from './services/notification-queue.service';
|
||||||
import { PushNotificationService } from './services/push-notification.service';
|
import { PushNotificationService } from './services/push-notification.service';
|
||||||
import { SmsNotificationService } from './services/sms-notification.service';
|
import { SmsAdaptorService } from './services/sms.adaptor';
|
||||||
import { SmsProcessor } from './processors/sms.processor';
|
import { SmsProcessor } from './processors/sms.processor';
|
||||||
import { PushProcessor } from './processors/push.processor';
|
import { PushProcessor } from './processors/push.processor';
|
||||||
import { NotificationsController } from './notifications.controller';
|
import { NotificationsController } from './notifications.controller';
|
||||||
@@ -18,10 +18,12 @@ import { NotificationListeners } from './listeners/notification.listeners';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { NotificationQueueNameEnum } from './constants/queue';
|
import { NotificationQueueNameEnum } from './constants/queue';
|
||||||
|
import { UtilsModule } from '../utils/utils.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
UtilsModule,
|
||||||
JwtModule,
|
JwtModule,
|
||||||
MikroOrmModule.forFeature([Notification, NotificationPreference, User, Restaurant]),
|
MikroOrmModule.forFeature([Notification, NotificationPreference, User, Restaurant]),
|
||||||
BullModule.registerQueue({ name: NotificationQueueNameEnum.SMS }, { name: NotificationQueueNameEnum.PUSH }),
|
BullModule.registerQueue({ name: NotificationQueueNameEnum.SMS }, { name: NotificationQueueNameEnum.PUSH }),
|
||||||
@@ -42,7 +44,7 @@ import { NotificationQueueNameEnum } from './constants/queue';
|
|||||||
NotificationPreferenceService,
|
NotificationPreferenceService,
|
||||||
NotificationQueueService,
|
NotificationQueueService,
|
||||||
PushNotificationService,
|
PushNotificationService,
|
||||||
SmsNotificationService,
|
SmsAdaptorService,
|
||||||
PushProcessor,
|
PushProcessor,
|
||||||
SmsProcessor,
|
SmsProcessor,
|
||||||
NotificationListeners,
|
NotificationListeners,
|
||||||
@@ -52,7 +54,7 @@ import { NotificationQueueNameEnum } from './constants/queue';
|
|||||||
NotificationPreferenceService,
|
NotificationPreferenceService,
|
||||||
NotificationQueueService,
|
NotificationQueueService,
|
||||||
PushNotificationService,
|
PushNotificationService,
|
||||||
SmsNotificationService,
|
SmsAdaptorService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class NotificationsModule {}
|
export class NotificationsModule {}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { InjectRepository } from '@mikro-orm/nestjs';
|
|||||||
import { EntityRepository, EntityManager } from '@mikro-orm/postgresql';
|
import { EntityRepository, EntityManager } from '@mikro-orm/postgresql';
|
||||||
import { Notification } from '../entities/notification.entity';
|
import { Notification } from '../entities/notification.entity';
|
||||||
import { NotificationQueueJob, NotificationQueueJobResult } from '../interfaces/notification-queue.interface';
|
import { NotificationQueueJob, NotificationQueueJobResult } from '../interfaces/notification-queue.interface';
|
||||||
import { NotificationQueueNameEnum } from '../constants/queue';
|
import { NotificationQueueNameEnum } from '../constants/queue';
|
||||||
import { PushNotificationService } from '../services/push-notification.service';
|
import { PushNotificationService } from '../services/push-notification.service';
|
||||||
|
|
||||||
@Processor(NotificationQueueNameEnum.PUSH)
|
@Processor(NotificationQueueNameEnum.PUSH)
|
||||||
@@ -41,16 +41,19 @@ export class PushProcessor extends WorkerHost {
|
|||||||
} else if (idempotencyKey) {
|
} else if (idempotencyKey) {
|
||||||
// Fallback: find by idempotency key if notificationId not provided
|
// Fallback: find by idempotency key if notificationId not provided
|
||||||
notification = await this.notificationRepository.findOne(
|
notification = await this.notificationRepository.findOne(
|
||||||
{ idempotencyKey },
|
{
|
||||||
{ populate: ['user', 'restaurant'] },
|
id: 'sdfpsdfpsdf',
|
||||||
|
},
|
||||||
|
// { idempotencyKey },
|
||||||
|
// { populate: ['user', 'restaurant'] },
|
||||||
);
|
);
|
||||||
if (notification && notification.sentAt) {
|
// if (notification && notification.sentAt) {
|
||||||
this.logger.warn(`Duplicate notification skipped: ${idempotencyKey}`);
|
// this.logger.warn(`Duplicate notification skipped: ${idempotencyKey}`);
|
||||||
return {
|
// return {
|
||||||
success: true,
|
// success: true,
|
||||||
notificationId: notification.id,
|
// notificationId: notification.id,
|
||||||
};
|
// };
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!notification) {
|
if (!notification) {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { Notification } from '../entities/notification.entity';
|
|||||||
import { NotificationQueueJob, NotificationQueueJobResult } from '../interfaces/notification-queue.interface';
|
import { NotificationQueueJob, NotificationQueueJobResult } from '../interfaces/notification-queue.interface';
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { User } from '../../users/entities/user.entity';
|
||||||
import { NotificationQueueNameEnum } from '../constants/queue';
|
import { NotificationQueueNameEnum } from '../constants/queue';
|
||||||
import { SmsNotificationService } from '../services/sms-notification.service';
|
import { SmsAdaptorService } from '../services/sms.adaptor';
|
||||||
|
import { NotificationTitleEnum } from '../interfaces/notification.interface';
|
||||||
|
import { ISmsResponse } from '../../utils/interface/sms';
|
||||||
|
|
||||||
@Processor(NotificationQueueNameEnum.SMS)
|
@Processor(NotificationQueueNameEnum.SMS)
|
||||||
export class SmsProcessor extends WorkerHost {
|
export class SmsProcessor extends WorkerHost {
|
||||||
@@ -17,79 +19,63 @@ export class SmsProcessor extends WorkerHost {
|
|||||||
@InjectRepository(Notification)
|
@InjectRepository(Notification)
|
||||||
private readonly notificationRepository: EntityRepository<Notification>,
|
private readonly notificationRepository: EntityRepository<Notification>,
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
private readonly smsNotificationService: SmsNotificationService,
|
private readonly smsAdaptorService: SmsAdaptorService,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async process(job: Job<NotificationQueueJob>): Promise<NotificationQueueJobResult> {
|
async process(job: Job<NotificationQueueJob>): Promise<NotificationQueueJobResult> {
|
||||||
const { restaurantId, userId, title, content, idempotencyKey, notificationId } = job.data;
|
const { restaurantId, userId, title } = job.data;
|
||||||
|
|
||||||
this.logger.log(`Processing SMS notification job: ${job.id} - Title: ${title}, Restaurant: ${restaurantId}`);
|
this.logger.log(`Processing SMS notification job: ${job.id} - Title: ${title},
|
||||||
|
Restaurant: ${restaurantId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find existing notification record (should already exist from NotificationService.sendNotification)
|
|
||||||
let notification: Notification | null = null;
|
|
||||||
|
|
||||||
if (notificationId) {
|
|
||||||
notification = await this.notificationRepository.findOne(
|
|
||||||
{ id: notificationId },
|
|
||||||
{ populate: ['user', 'restaurant'] },
|
|
||||||
);
|
|
||||||
if (!notification) {
|
|
||||||
throw new Error(`Notification ${notificationId} not found`);
|
|
||||||
}
|
|
||||||
} else if (idempotencyKey) {
|
|
||||||
// Fallback: find by idempotency key if notificationId not provided
|
|
||||||
notification = await this.notificationRepository.findOne(
|
|
||||||
{ idempotencyKey },
|
|
||||||
{ populate: ['user', 'restaurant'] },
|
|
||||||
);
|
|
||||||
if (notification && notification.sentAt) {
|
|
||||||
this.logger.warn(`Duplicate notification skipped: ${idempotencyKey}`);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
notificationId: notification.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!notification) {
|
|
||||||
throw new Error(`Notification not found for job ${job.id}. notificationId or idempotencyKey required.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user phone number for SMS
|
|
||||||
if (!userId || !notification.user) {
|
|
||||||
this.logger.warn(`No user found for SMS notification ${notification.id}`);
|
|
||||||
throw new Error('User is required for SMS notifications');
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.em.findOne(User, { id: userId });
|
const user = await this.em.findOne(User, { id: userId });
|
||||||
if (!user || !user.phone) {
|
if (!user || !user.phone) {
|
||||||
this.logger.warn(`User ${userId} not found or has no phone number`);
|
this.logger.warn(`User ${userId} not found or has no phone number`);
|
||||||
throw new Error('User phone number is required for SMS notifications');
|
throw new Error('User phone number is required for SMS notifications');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send SMS notification
|
// Check if the notification type is supported for SMS
|
||||||
const smsResult = await this.smsNotificationService.send({
|
if (title !== NotificationTitleEnum.ORDER_CREATED && title !== NotificationTitleEnum.PAYMENT_SUCCESS) {
|
||||||
phoneNumber: user.phone,
|
throw new Error(`SMS notification type ${title} is not supported`);
|
||||||
message: content,
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (!smsResult.success) {
|
// Send SMS notification
|
||||||
throw new Error(smsResult.error || 'Failed to send SMS notification');
|
let smsResult: ISmsResponse;
|
||||||
|
if (title === NotificationTitleEnum.ORDER_CREATED) {
|
||||||
|
smsResult = await this.smsAdaptorService.sendNotifySms({
|
||||||
|
phone: user.phone,
|
||||||
|
subject: NotificationTitleEnum.ORDER_CREATED,
|
||||||
|
payload: {
|
||||||
|
orderNumber: '1234567890',
|
||||||
|
orderAmount: 100000,
|
||||||
|
orderDate: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
smsResult = await this.smsAdaptorService.sendNotifySms({
|
||||||
|
phone: user.phone,
|
||||||
|
subject: NotificationTitleEnum.PAYMENT_SUCCESS,
|
||||||
|
payload: {
|
||||||
|
amount: 100000,
|
||||||
|
date: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (smsResult.status !== 1) {
|
||||||
|
throw new Error(smsResult.message ?? 'Failed to send SMS notification');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark notification as sent
|
// Mark notification as sent
|
||||||
notification.sentAt = new Date();
|
|
||||||
await this.em.persistAndFlush(notification);
|
|
||||||
|
|
||||||
this.logger.log(`SMS notification ${notification.id} sent successfully to ${user.phone}`);
|
this.logger.log(`SMS notification sent successfully to ${user.phone}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
notificationId: notification.id,
|
notificationId: 'sdfpsdfpsdf',
|
||||||
providerResponse: { messageId: smsResult.messageId },
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error processing SMS notification job ${job.id}:`, error);
|
this.logger.error(`Error processing SMS notification job ${job.id}:`, error);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { NotificationPreference } from '../entities/notification-preference.enti
|
|||||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
import { CreatePreferenceDto } from '../dto/create-preference.dto';
|
import { CreatePreferenceDto } from '../dto/create-preference.dto';
|
||||||
import { UpdatePreferenceDto } from '../dto/update-preference.dto';
|
import { UpdatePreferenceDto } from '../dto/update-preference.dto';
|
||||||
import { NotificationTitle } from '../entities/notification.entity';
|
import { NotificationTitleEnum } from '../interfaces/notification.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationPreferenceService {
|
export class NotificationPreferenceService {
|
||||||
@@ -34,7 +34,7 @@ export class NotificationPreferenceService {
|
|||||||
|
|
||||||
async findByRestaurantAndType(
|
async findByRestaurantAndType(
|
||||||
restaurantId: string,
|
restaurantId: string,
|
||||||
title: NotificationTitle,
|
title: NotificationTitleEnum,
|
||||||
): Promise<NotificationPreference | null> {
|
): Promise<NotificationPreference | null> {
|
||||||
return this.em.findOne(NotificationPreference, {
|
return this.em.findOne(NotificationPreference, {
|
||||||
restaurant: { id: restaurantId },
|
restaurant: { id: restaurantId },
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
import { Notification, NotificationTitle } from '../entities/notification.entity';
|
import { Notification } from '../entities/notification.entity';
|
||||||
import { NotificationPreferenceService } from './notification-preference.service';
|
import { NotificationPreferenceService } from './notification-preference.service';
|
||||||
import { NotificationQueueService } from './notification-queue.service';
|
import { NotificationQueueService } from './notification-queue.service';
|
||||||
import { NotificationQueueJob } from '../interfaces/notification-queue.interface';
|
import { NotificationQueueJob } from '../interfaces/notification-queue.interface';
|
||||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { User } from '../../users/entities/user.entity';
|
||||||
import { NotificationType } from '../interfaces/notification.interface';
|
import { NotificationType } from '../interfaces/notification.interface';
|
||||||
|
import { NotificationTitleEnum } from '../interfaces/notification.interface';
|
||||||
|
|
||||||
export interface SendNotificationParams {
|
export interface SendNotificationParams {
|
||||||
restaurantId: string;
|
restaurantId: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
title: NotificationTitle;
|
title: NotificationTitleEnum;
|
||||||
content: string;
|
content: string;
|
||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,7 @@ export class NotificationService {
|
|||||||
user,
|
user,
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
idempotencyKey: finalIdempotencyKey,
|
// idempotencyKey: finalIdempotencyKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.em.persistAndFlush(notification);
|
await this.em.persistAndFlush(notification);
|
||||||
@@ -125,7 +126,11 @@ export class NotificationService {
|
|||||||
await this.em.removeAndFlush(notification as any);
|
await this.em.removeAndFlush(notification as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByRestaurantAndType(restaurantId: string, title: NotificationTitle, limit = 50): Promise<Notification[]> {
|
async findByRestaurantAndType(
|
||||||
|
restaurantId: string,
|
||||||
|
title: NotificationTitleEnum,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<Notification[]> {
|
||||||
return this.em.find(
|
return this.em.find(
|
||||||
Notification,
|
Notification,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
export interface SmsNotificationPayload {
|
|
||||||
phoneNumber: string;
|
|
||||||
message: string;
|
|
||||||
templateId?: string;
|
|
||||||
parameters?: Array<{ name: string; value: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SmsNotificationResponse {
|
|
||||||
success: boolean;
|
|
||||||
messageId?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SmsNotificationService {
|
|
||||||
private readonly logger = new Logger(SmsNotificationService.name);
|
|
||||||
private readonly baseURL: string;
|
|
||||||
private readonly apiKey: string;
|
|
||||||
private readonly defaultTemplateId?: string;
|
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
|
||||||
this.baseURL = this.configService.getOrThrow<string>('SMS_BASE_URL');
|
|
||||||
this.apiKey = this.configService.getOrThrow<string>('SMS_API_KEY');
|
|
||||||
this.defaultTemplateId = this.configService.get<string>('SMS_PATTERN_OTP');
|
|
||||||
}
|
|
||||||
|
|
||||||
async send(payload: SmsNotificationPayload): Promise<SmsNotificationResponse> {
|
|
||||||
try {
|
|
||||||
// If templateId is provided, use template-based SMS
|
|
||||||
if (payload.templateId || this.defaultTemplateId) {
|
|
||||||
return await this.sendTemplateSms(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, send simple SMS
|
|
||||||
return await this.sendSimpleSms(payload);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to send SMS: ${error}`);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendTemplateSms(payload: SmsNotificationPayload): Promise<SmsNotificationResponse> {
|
|
||||||
const templateId = payload.templateId || this.defaultTemplateId;
|
|
||||||
|
|
||||||
if (!templateId) {
|
|
||||||
throw new HttpException('Template ID is required for template SMS', HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
const smsData = {
|
|
||||||
Parameters: payload.parameters || [{ name: 'message', value: payload.message }],
|
|
||||||
Mobile: payload.phoneNumber,
|
|
||||||
TemplateId: templateId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const url = `${this.baseURL}/send/verify`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data } = await axios.post<{ status: number; message: string }>(url, smsData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-API-KEY': this.apiKey,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.status !== 1) {
|
|
||||||
throw new HttpException(data.message || 'SMS sending failed', HttpStatus.BAD_GATEWAY);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`SMS sent successfully to ${payload.phoneNumber}`);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
messageId: data.message,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`SMS sending failed: ${JSON.stringify(error)}`);
|
|
||||||
throw new HttpException('The SMS was not sent. Please try again later.', HttpStatus.BAD_GATEWAY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendSimpleSms(payload: SmsNotificationPayload): Promise<SmsNotificationResponse> {
|
|
||||||
// If your SMS provider supports simple text messages, implement it here
|
|
||||||
// For now, we'll use the template method as fallback
|
|
||||||
return this.sendTemplateSms({
|
|
||||||
...payload,
|
|
||||||
parameters: [{ name: 'message', value: payload.message }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { SmsService } from 'src/modules/utils/sms.service';
|
||||||
|
import { ISmsNotifyPayload } from '../interfaces/notification.interface';
|
||||||
|
import { NotificationTitleEnum } from '../interfaces/notification.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SmsAdaptorService {
|
||||||
|
private readonly smsPatternOrderCreated: string;
|
||||||
|
private readonly smsPatternPaymentSuccess: string;
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly smsService: SmsService,
|
||||||
|
) {
|
||||||
|
this.smsPatternOrderCreated = this.configService.getOrThrow<string>('SMS_PATTERN_ORDER_CREATED');
|
||||||
|
this.smsPatternPaymentSuccess = this.configService.getOrThrow<string>('SMS_PATTERN_PAYMENT_SUCCESS');
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendNotifySms(params: ISmsNotifyPayload) {
|
||||||
|
const { phone, subject, payload } = params;
|
||||||
|
let templateId!: string;
|
||||||
|
switch (subject) {
|
||||||
|
case NotificationTitleEnum.ORDER_CREATED:
|
||||||
|
templateId = this.smsPatternOrderCreated;
|
||||||
|
break;
|
||||||
|
case NotificationTitleEnum.PAYMENT_SUCCESS:
|
||||||
|
templateId = this.smsPatternPaymentSuccess;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return this.smsService.sendSms({
|
||||||
|
phone,
|
||||||
|
parameters: payload,
|
||||||
|
templateId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export interface ISmsResponse {
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISmsParams {
|
||||||
|
phone: string;
|
||||||
|
parameters: Record<string, string | number | Date>;
|
||||||
|
templateId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISmsBodyParameters {
|
||||||
|
Mobile: string;
|
||||||
|
TemplateId: string;
|
||||||
|
Parameters: { name: string; value: string }[];
|
||||||
|
}
|
||||||
@@ -1,24 +1,7 @@
|
|||||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
// import { HttpService } from '@nestjs/axios';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
// import { lastValueFrom } from 'rxjs';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { ISmsParams, ISmsResponse, ISmsBodyParameters } from './interface/sms';
|
||||||
export interface IParameterArray {
|
|
||||||
name: 'otp';
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ISmsResponse {
|
|
||||||
status: number;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ISmsVerifyBody {
|
|
||||||
Parameters: IParameterArray[];
|
|
||||||
Mobile: string;
|
|
||||||
TemplateId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsService {
|
export class SmsService {
|
||||||
@@ -31,15 +14,26 @@ export class SmsService {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.baseURL = this.configService.getOrThrow<string>('SMS_BASE_URL');
|
this.baseURL = this.configService.getOrThrow<string>('SMS_BASE_URL');
|
||||||
this.OTP = this.configService.getOrThrow<string>('SMS_PATTERN_OTP');
|
|
||||||
this.apiKey = this.configService.getOrThrow<string>('SMS_API_KEY');
|
this.apiKey = this.configService.getOrThrow<string>('SMS_API_KEY');
|
||||||
|
this.OTP = this.configService.getOrThrow<string>('SMS_PATTERN_OTP');
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendOtp(mobile: string, otp: string) {
|
sendotp(phone: string, otp: string) {
|
||||||
const smsData: ISmsVerifyBody = {
|
return this.sendSms({
|
||||||
Parameters: [{ name: 'otp', value: otp }],
|
phone,
|
||||||
Mobile: mobile,
|
parameters: { otp },
|
||||||
TemplateId: this.OTP,
|
templateId: this.OTP,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendSms(params: ISmsParams) {
|
||||||
|
const { parameters, phone, templateId } = params;
|
||||||
|
const parametersArray = Object.entries(parameters).map(([name, value]) => ({ name, value: value.toString() }));
|
||||||
|
|
||||||
|
const smsData: ISmsBodyParameters = {
|
||||||
|
Parameters: parametersArray,
|
||||||
|
Mobile: phone,
|
||||||
|
TemplateId: templateId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const url = `${this.baseURL}/send/verify`;
|
const url = `${this.baseURL}/send/verify`;
|
||||||
|
|||||||
Reference in New Issue
Block a user