notification

This commit is contained in:
2025-12-08 22:04:01 +03:30
parent 060570f847
commit 9c93feee68
19 changed files with 2603 additions and 88 deletions
+1226 -69
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -41,6 +41,7 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "^11.0.1",
"@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^11.0.1",
@@ -49,14 +50,17 @@
"@nestjs/swagger": "^11.2.1",
"@nestjs/throttler": "^6.4.0",
"axios": "^1.13.1",
"bullmq": "^5.65.1",
"cache-manager": "^7.2.4",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"dayjs": "^1.11.19",
"fastify": "^5.6.1",
"firebase-admin": "^13.6.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"ulid": "^3.0.1"
"ulid": "^3.0.1",
"uuid": "^13.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
@@ -70,6 +74,7 @@
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
+5 -18
View File
@@ -1,18 +1,12 @@
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import dataBaseConfig from './config/mikro-orm.config';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ConfigModule } from '@nestjs/config';
import { UserModule } from './modules/users/user.module';
// import { CacheModule } from '@nestjs/cache-manager';
// import { cacheConfig } from './config/cache.config';
import { UtilsModule } from './modules/utils/utils.module';
// import { HttpModule } from '@nestjs/axios';
// import { httpConfig } from './config/http.config';
import { AuthModule } from './modules/auth/auth.module';
// import { JwtModule } from '@nestjs/jwt';
import { UploaderModule } from './modules/uploader/uploader.module';
import { AdminModule } from './modules/admin/admin.module';
// import { CacheService } from './modules/utils/cache.service';
import { ThrottlerModule } from '@nestjs/throttler';
import { ScheduleModule } from '@nestjs/schedule';
import { RestaurantsModule } from './modules/restaurants/restaurants.module';
@@ -24,22 +18,13 @@ import { DeliveryModule } from './modules/delivery/delivery.module';
import { OrdersModule } from './modules/orders/orders.module';
import { CouponModule } from './modules/coupons/coupon.module';
import { ReviewModule } from './modules/review/review.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { EventEmitterModule } from '@nestjs/event-emitter';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, cache: true }),
MikroOrmModule.forRootAsync(dataBaseConfig),
// CacheModule.registerAsync(cacheConfig()),
// JwtModule.registerAsync({
// useFactory: (configService: ConfigService) => ({
// global: true,
// secret: configService.getOrThrow<string>('JWT_SECRET'),
// // signOptions: {
// // expiresIn: configService.getOrThrow<number>('JWT_EXPIRATION_TIME'),
// // },
// }),
// inject: [ConfigService],
// }),
UserModule,
UtilsModule,
AuthModule,
@@ -61,6 +46,8 @@ import { ReviewModule } from './modules/review/review.module';
OrdersModule,
CouponModule,
ReviewModule,
NotificationsModule,
EventEmitterModule.forRoot(),
],
controllers: [],
providers: [],
@@ -0,0 +1,41 @@
import { IsString, IsNotEmpty, IsOptional, IsArray, IsObject } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateNotificationDto {
@ApiProperty({ description: 'Notification title' })
@IsString()
@IsNotEmpty()
title: string;
@ApiProperty({ description: 'Notification message' })
@IsString()
@IsNotEmpty()
message: string;
@ApiProperty({ description: 'Notification type' })
@IsString()
@IsNotEmpty()
type: string;
@ApiPropertyOptional({ description: 'User ID' })
@IsString()
@IsOptional()
userId?: string;
@ApiPropertyOptional({ description: 'Phone number for SMS' })
@IsString()
@IsOptional()
phoneNumber?: string;
@ApiPropertyOptional({ description: 'Push notification tokens', type: [String] })
@IsArray()
@IsString({ each: true })
@IsOptional()
pushTokens?: string[];
@ApiPropertyOptional({ description: 'Additional data' })
@IsObject()
@IsOptional()
data?: Record<string, any>;
}
@@ -0,0 +1,35 @@
import { IsString, IsNotEmpty, IsBoolean, IsObject, IsOptional, ValidateNested } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
class ChannelsDto {
@ApiPropertyOptional({ description: 'Enable SMS channel' })
@IsBoolean()
@IsOptional()
sms?: boolean;
@ApiPropertyOptional({ description: 'Enable push notification channel' })
@IsBoolean()
@IsOptional()
push?: boolean;
}
export class CreateNotificationPreferenceDto {
@ApiProperty({ description: 'Notification type (e.g., ORDER_CONFIRMED, PAYMENT_FAILED)' })
@IsString()
@IsNotEmpty()
notificationType: string;
@ApiProperty({ description: 'Notification channels configuration', type: ChannelsDto })
@ValidateNested()
@Type(() => ChannelsDto)
@IsObject()
@IsNotEmpty()
channels: ChannelsDto;
@ApiPropertyOptional({ description: 'Whether the preference is enabled', default: true })
@IsBoolean()
@IsOptional()
enabled?: boolean;
}
@@ -0,0 +1,38 @@
import { IsString, IsNotEmpty, IsOptional, IsObject } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class SendNotificationDto {
@ApiProperty({ description: 'Restaurant ID (ULID)' })
@IsString()
@IsNotEmpty()
restaurantId: string;
@ApiPropertyOptional({ description: 'User ID (ULID)' })
@IsString()
@IsOptional()
userId?: string;
@ApiProperty({ description: 'Notification type (e.g., ORDER_CONFIRMED, PAYMENT_FAILED)' })
@IsString()
@IsNotEmpty()
notificationType: string;
@ApiProperty({ description: 'Notification payload' })
@IsObject()
@IsNotEmpty()
payload: {
title?: string;
message: string;
body?: string;
phoneNumber?: string;
pushToken?: string;
data?: Record<string, any>;
templateId?: string;
parameters?: Array<{ name: string; value: string }>;
};
@ApiPropertyOptional({ description: 'Idempotency key to prevent duplicates' })
@IsString()
@IsOptional()
idempotencyKey?: string;
}
@@ -0,0 +1,24 @@
import { Entity, Property, ManyToOne, Unique } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
export interface NotificationChannels {
sms?: boolean;
push?: boolean;
}
@Entity({ tableName: 'notification_preferences' })
@Unique({ properties: ['restaurant', 'notificationType'] })
export class NotificationPreference extends BaseEntity {
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@Property()
notificationType!: string; // e.g., ORDER_CONFIRMED, PAYMENT_FAILED
@Property({ type: 'json' })
channels!: NotificationChannels; // { sms: true, push: true }
@Property({ default: true })
enabled: boolean = true;
}
@@ -0,0 +1,50 @@
import { Entity, Property, ManyToOne, Enum } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { User } from '../../users/entities/user.entity';
export enum NotificationStatus {
PENDING = 'pending',
SENT = 'sent',
FAILED = 'failed',
SKIPPED = 'skipped',
}
export enum NotificationChannel {
SMS = 'sms',
PUSH = 'push',
}
@Entity({ tableName: 'notifications' })
export class Notification extends BaseEntity {
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@ManyToOne(() => User, { nullable: true })
user?: User;
@Property()
notificationType!: string; // e.g., ORDER_CONFIRMED, PAYMENT_FAILED
@Enum(() => NotificationChannel)
channel!: NotificationChannel;
@Property({ type: 'json' })
payload!: Record<string, any>; // raw outbound payload
@Enum(() => NotificationStatus)
status: NotificationStatus = NotificationStatus.PENDING;
@Property({ type: 'int', default: 0 })
attemptCount: number = 0;
@Property({ nullable: true, unique: true })
idempotencyKey?: string;
@Property({ nullable: true })
sentAt?: Date;
}
@@ -0,0 +1,27 @@
export class OrderCreatedEvent {
constructor(
public readonly restaurantId: string,
public readonly userId: string,
public readonly orderId: string,
public readonly orderData: any,
) {}
}
export class ReviewCreatedEvent {
constructor(
public readonly restaurantId: string,
public readonly userId: string,
public readonly reviewId: string,
public readonly reviewData: any,
) {}
}
export class OrderStatusChangedEvent {
constructor(
public readonly restaurantId: string,
public readonly userId: string,
public readonly orderId: string,
public readonly oldStatus: string,
public readonly newStatus: string,
) {}
}
@@ -0,0 +1,19 @@
import { NotificationChannel } from '../entities/notification.entity';
export interface NotificationQueueJob {
restaurantId: string;
userId?: string;
notificationType: string;
channel: NotificationChannel;
payload: Record<string, any>;
idempotencyKey?: string;
notificationId?: string; // For retries
}
export interface NotificationQueueJobResult {
success: boolean;
notificationId: string;
providerResponse?: Record<string, any>;
error?: string;
}
@@ -0,0 +1,84 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { NotificationService } from '../services/notification.service';
import { OrderCreatedEvent, ReviewCreatedEvent, OrderStatusChangedEvent } from '../events/notification.events';
@Injectable()
export class NotificationListeners {
private readonly logger = new Logger(NotificationListeners.name);
constructor(private readonly notificationService: NotificationService) {}
@OnEvent('order.created')
async handleOrderCreated(event: OrderCreatedEvent) {
try {
this.logger.log(`Order created event received: ${event.orderId}`);
await this.notificationService.sendNotification({
restaurantId: event.restaurantId,
userId: event.userId,
notificationType: 'order.created',
payload: {
title: 'Order Confirmed',
message: `Your order #${event.orderId} has been confirmed`,
data: { orderId: event.orderId },
},
});
} catch (error) {
this.logger.error(
`Failed to send notification for order.created event: ${event.orderId}`,
error instanceof Error ? error.stack : String(error),
);
}
}
@OnEvent('review.created')
async handleReviewCreated(event: ReviewCreatedEvent) {
try {
this.logger.log(`Review created event received: ${event.reviewId}`);
await this.notificationService.sendNotification({
restaurantId: event.restaurantId,
userId: event.userId,
notificationType: 'review.created',
payload: {
title: 'Review Received',
message: 'Thank you for your review!',
data: { reviewId: event.reviewId },
},
});
} catch (error) {
this.logger.error(
`Failed to send notification for review.created event: ${event.reviewId}`,
error instanceof Error ? error.stack : String(error),
);
}
}
@OnEvent('order.status.changed')
async handleOrderStatusChanged(event: OrderStatusChangedEvent) {
try {
this.logger.log(`Order status changed: ${event.orderId} from ${event.oldStatus} to ${event.newStatus}`);
await this.notificationService.sendNotification({
restaurantId: event.restaurantId,
userId: event.userId,
notificationType: 'order.status.changed',
payload: {
title: 'Order Status Updated',
message: `Your order #${event.orderId} is now ${event.newStatus}`,
data: {
orderId: event.orderId,
oldStatus: event.oldStatus,
newStatus: event.newStatus,
},
},
});
} catch (error) {
this.logger.error(
`Failed to send notification for order.status.changed event: ${event.orderId}`,
error instanceof Error ? error.stack : String(error),
);
}
}
}
@@ -0,0 +1,152 @@
import { Controller, Post, Get, Body, Param, UseGuards, Query, Patch, Delete } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger';
import { NotificationService } from './services/notification.service';
import { NotificationPreferenceService } from './services/notification-preference.service';
import { SendNotificationDto } from './dto/send-notification.dto';
import { CreateNotificationPreferenceDto } from './dto/create-preference.dto';
import { AuthGuard } from '../auth/guards/auth.guard';
import { UserId } from '../../common/decorators/user-id.decorator';
import { AdminAuthGuard } from '../auth/guards/adminAuth.guard';
import { RestId } from '../../common/decorators/rest-id.decorator';
@ApiTags('notifications')
@Controller()
export class NotificationsController {
constructor(
private readonly notificationService: NotificationService,
private readonly preferenceService: NotificationPreferenceService,
) {}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Post('admin/notifications/send')
@ApiOperation({ summary: 'Send a notification (queued)' })
async sendNotification(@Body() dto: SendNotificationDto) {
return this.notificationService.sendNotification(dto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Get('admin/notifications')
@ApiOperation({ summary: 'Get notifications for a restaurant' })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'notificationType', required: false, type: String })
async getRestaurantNotifications(
@RestId() restaurantId: string,
@Query('limit') limit?: number,
@Query('notificationType') notificationType?: string,
) {
if (notificationType) {
return this.notificationService.findByRestaurantAndType(
restaurantId,
notificationType,
limit ? parseInt(limit.toString(), 10) : 50,
);
}
return this.notificationService.findByRestaurant(restaurantId, limit ? parseInt(limit.toString(), 10) : 50);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Get('admin/notifications/:id')
@ApiOperation({ summary: 'Get a notification by ID' })
@ApiParam({ name: 'id', description: 'Notification ID' })
async getNotification(@Param('id') id: string) {
return this.notificationService.findOne(id);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Post('admin/notifications/:id/retry')
@ApiOperation({ summary: 'Retry a failed notification' })
@ApiParam({ name: 'id', description: 'Notification ID' })
async retryNotification(@Param('id') id: string) {
await this.notificationService.retryFailedNotification(id);
return { message: 'Notification queued for retry' };
}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@Get('public/notifications')
@ApiOperation({ summary: 'Get user notifications' })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getUserNotifications(@UserId() userId: string, @Query('limit') limit?: number) {
return this.notificationService.findByUserId(userId, limit ? parseInt(limit.toString(), 10) : 50);
}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@Get('public/notifications/:id')
@ApiOperation({ summary: 'Get a notification by ID' })
@ApiParam({ name: 'id', description: 'Notification ID' })
async getUserNotification(@Param('id') id: string) {
return this.notificationService.findOne(id);
}
// Preference endpoints
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Post('admin/notification-preferences')
@ApiOperation({ summary: 'Create or update notification preference' })
async createPreference(@RestId() restaurantId: string, @Body() dto: CreateNotificationPreferenceDto) {
return this.preferenceService.createOrUpdate(
restaurantId,
dto.notificationType,
dto.channels,
dto.enabled ?? true,
);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Get('admin/notification-preferences')
@ApiOperation({ summary: 'Get all notification preferences for a restaurant' })
async getPreferences(@RestId() restaurantId: string) {
return this.preferenceService.findByRestaurant(restaurantId);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Get('admin/notification-preferences/:type')
@ApiOperation({ summary: 'Get a specific notification preference' })
@ApiParam({ name: 'type', description: 'Notification type' })
async getPreference(@RestId() restaurantId: string, @Param('type') notificationType: string) {
return this.preferenceService.findByRestaurantAndType(restaurantId, notificationType);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Patch('admin/notification-preferences/:type/enabled')
@ApiOperation({ summary: 'Enable or disable a notification preference' })
@ApiParam({ name: 'type', description: 'Notification type' })
async updatePreferenceEnabled(
@RestId() restaurantId: string,
@Param('type') notificationType: string,
@Body('enabled') enabled: boolean,
) {
return this.preferenceService.updateEnabled(restaurantId, notificationType, enabled);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Patch('admin/notification-preferences/:type/channels')
@ApiOperation({ summary: 'Update notification channels' })
@ApiParam({ name: 'type', description: 'Notification type' })
async updatePreferenceChannels(
@RestId() restaurantId: string,
@Param('type') notificationType: string,
@Body('channels') channels: { sms?: boolean; push?: boolean },
) {
return this.preferenceService.updateChannels(restaurantId, notificationType, channels);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Delete('admin/notification-preferences/:type')
@ApiOperation({ summary: 'Delete a notification preference' })
@ApiParam({ name: 'type', description: 'Notification type' })
async deletePreference(@RestId() restaurantId: string, @Param('type') notificationType: string) {
await this.preferenceService.delete(restaurantId, notificationType);
return { message: 'Preference deleted successfully' };
}
}
@@ -0,0 +1,58 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { BullModule } from '@nestjs/bullmq';
import { JwtModule } from '@nestjs/jwt';
import { Notification } from './entities/notification.entity';
import { NotificationPreference } from './entities/notification-preference.entity';
import { NotificationService } from './services/notification.service';
import { NotificationPreferenceService } from './services/notification-preference.service';
import { NotificationQueueService } from './services/notification-queue.service';
import { PushNotificationService } from './services/push-notification.service';
import { SmsNotificationService } from './services/sms-notification.service';
import { NotificationProcessor } from './processors/notification.processor';
import { NotificationsController } from './notifications.controller';
import { User } from '../users/entities/user.entity';
import { Restaurant } from '../restaurants/entities/restaurant.entity';
import { NotificationListeners } from './listeners/notification.listeners';
import { AuthModule } from '../auth/auth.module';
import { ConfigService } from '@nestjs/config';
@Module({
imports: [
AuthModule,
JwtModule,
MikroOrmModule.forFeature([Notification, NotificationPreference, User, Restaurant]),
BullModule.registerQueue({
name: 'notification-queue',
}),
BullModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
connection: {
host: config.get('REDIS_HOST') || 'captain.dev.danakcorp.com',
port: config.get('REDIS_PORT') || 50601,
password: config.get('REDIS_PASSWORD'), // Pull from .env
},
}),
}),
],
controllers: [NotificationsController],
providers: [
NotificationService,
NotificationPreferenceService,
NotificationQueueService,
PushNotificationService,
SmsNotificationService,
NotificationProcessor,
NotificationListeners,
],
exports: [
NotificationService,
NotificationPreferenceService,
NotificationQueueService,
PushNotificationService,
SmsNotificationService,
],
})
export class NotificationsModule {}
@@ -0,0 +1,248 @@
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository, EntityManager } from '@mikro-orm/postgresql';
import { Notification, NotificationStatus, NotificationChannel } from '../entities/notification.entity';
import { NotificationQueueJob, NotificationQueueJobResult } from '../interfaces/notification-queue.interface';
import { PushNotificationService } from '../services/push-notification.service';
import { SmsNotificationService } from '../services/sms-notification.service';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { User } from '../../users/entities/user.entity';
@Processor('notification-queue')
export class NotificationProcessor extends WorkerHost {
private readonly logger = new Logger(NotificationProcessor.name);
constructor(
@InjectRepository(Notification)
private readonly notificationRepository: EntityRepository<Notification>,
private readonly em: EntityManager,
private readonly pushNotificationService: PushNotificationService,
private readonly smsNotificationService: SmsNotificationService,
) {
super();
}
async process(job: Job<NotificationQueueJob>): Promise<NotificationQueueJobResult> {
const { restaurantId, userId, notificationType, channel, payload, idempotencyKey, notificationId } = job.data;
this.logger.log(
`Processing notification job: ${job.id} - Type: ${notificationType}, Channel: ${channel}, Restaurant: ${restaurantId}`,
);
try {
// Find or create notification record
let notification: Notification | null = null;
if (notificationId) {
// Retry case - find existing notification
notification = await this.notificationRepository.findOne({ id: notificationId });
if (!notification) {
throw new Error(`Notification ${notificationId} not found for retry`);
}
} else {
// Check for duplicate using idempotency key
if (idempotencyKey) {
const existing = await this.notificationRepository.findOne({ idempotencyKey });
if (existing && existing.status === NotificationStatus.SENT) {
this.logger.warn(`Duplicate notification skipped: ${idempotencyKey}`);
return {
success: true,
notificationId: existing.id,
};
}
if (existing) {
notification = existing;
}
}
// Create new notification if not found
if (!notification) {
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
if (!restaurant) {
throw new Error(`Restaurant ${restaurantId} not found`);
}
const user = userId ? await this.em.findOne(User, { id: userId }) : undefined;
notification = this.em.create(Notification, {
restaurant,
user,
notificationType,
channel,
payload,
idempotencyKey,
status: NotificationStatus.PENDING,
attemptCount: 0,
});
await this.em.persistAndFlush(notification);
}
}
// Increment attempt count
notification.attemptCount += 1;
await this.em.persistAndFlush(notification);
// Send notification based on channel
let providerResponse: Record<string, any> | undefined;
let success = false;
switch (channel) {
case NotificationChannel.PUSH: {
const result = await this.sendPushNotification(notification);
success = result.success;
providerResponse = result.providerResponse;
break;
}
case NotificationChannel.SMS: {
const result = await this.sendSmsNotification(notification);
success = result.success;
providerResponse = result.providerResponse;
break;
}
default:
throw new Error(`Unsupported notification channel: ${channel}`);
}
// Update notification status
if (success) {
notification.status = NotificationStatus.SENT;
notification.sentAt = new Date();
this.logger.log(`Notification ${notification.id} sent successfully`);
} else {
notification.status = NotificationStatus.FAILED;
this.logger.error(`Notification ${notification.id} failed to send`);
}
await this.em.persistAndFlush(notification);
return {
success,
notificationId: notification.id,
providerResponse,
};
} catch (error) {
this.logger.error(`Error processing notification job ${job.id}:`, error);
// Update notification status if it exists
if (notificationId) {
const notification = await this.notificationRepository.findOne({ id: notificationId });
if (notification) {
notification.status = NotificationStatus.FAILED;
await this.em.persistAndFlush(notification);
}
}
throw error;
}
}
private async sendPushNotification(notification: Notification): Promise<{
success: boolean;
providerResponse?: Record<string, any>;
}> {
try {
const pushToken = notification.payload.pushToken;
if (!pushToken) {
this.logger.warn(`No push token in payload for notification ${notification.id}`);
return {
success: false,
providerResponse: { error: 'No push token provided' },
};
}
const response = await this.pushNotificationService.sendToToken({
title: notification.payload.title || 'Notification',
body: notification.payload.message || notification.payload.body || '',
token: pushToken,
data: notification.payload.data,
});
if (response.success) {
return {
success: true,
providerResponse: {
messageId: response.messageId,
success: true,
},
};
} else {
return {
success: false,
providerResponse: {
error: response.error,
success: false,
},
};
}
} catch (error) {
return {
success: false,
providerResponse: {
error: error instanceof Error ? error.message : 'Unknown error',
success: false,
},
};
}
}
private async sendSmsNotification(notification: Notification): Promise<{
success: boolean;
providerResponse?: Record<string, any>;
}> {
try {
const phoneNumber = notification.payload.phoneNumber;
if (!phoneNumber) {
this.logger.warn(`No phone number in payload for notification ${notification.id}`);
return {
success: false,
providerResponse: { error: 'No phone number provided' },
};
}
const response = await this.smsNotificationService.send({
phoneNumber,
message: notification.payload.message || notification.payload.body || '',
templateId: notification.payload.templateId,
parameters: notification.payload.parameters,
});
if (response.success) {
return {
success: true,
providerResponse: {
messageId: response.messageId,
success: true,
},
};
} else {
return {
success: false,
providerResponse: {
error: response.error,
success: false,
},
};
}
} catch (error) {
return {
success: false,
providerResponse: {
error: error instanceof Error ? error.message : 'Unknown error',
success: false,
},
};
}
}
@OnWorkerEvent('completed')
onCompleted(job: Job) {
this.logger.log(`Notification job ${job.id} completed`);
}
@OnWorkerEvent('failed')
onFailed(job: Job, error: Error) {
this.logger.error(`Notification job ${job.id} failed:`, error);
}
}
@@ -0,0 +1,93 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql';
import { NotificationPreference, NotificationChannels } from '../entities/notification-preference.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
@Injectable()
export class NotificationPreferenceService {
constructor(private readonly em: EntityManager) {}
async createOrUpdate(
restaurantId: string,
notificationType: string,
channels: NotificationChannels,
enabled: boolean = true,
): Promise<NotificationPreference> {
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
if (!restaurant) {
throw new NotFoundException('Restaurant not found');
}
let preference = await this.em.findOne(NotificationPreference, {
restaurant: { id: restaurantId },
notificationType,
});
if (preference) {
preference.channels = channels;
preference.enabled = enabled;
} else {
preference = this.em.create(NotificationPreference, {
restaurant,
notificationType,
channels,
enabled,
});
}
await this.em.persistAndFlush(preference);
return preference;
}
async findByRestaurant(restaurantId: string): Promise<NotificationPreference[]> {
return this.em.find(NotificationPreference, {
restaurant: { id: restaurantId },
});
}
async findByRestaurantAndType(
restaurantId: string,
notificationType: string,
): Promise<NotificationPreference | null> {
return this.em.findOne(NotificationPreference, {
restaurant: { id: restaurantId },
notificationType,
});
}
async updateEnabled(restaurantId: string, notificationType: string, enabled: boolean): Promise<NotificationPreference> {
const preference = await this.findByRestaurantAndType(restaurantId, notificationType);
if (!preference) {
throw new NotFoundException('Notification preference not found');
}
preference.enabled = enabled;
await this.em.persistAndFlush(preference);
return preference;
}
async updateChannels(
restaurantId: string,
notificationType: string,
channels: NotificationChannels,
): Promise<NotificationPreference> {
const preference = await this.findByRestaurantAndType(restaurantId, notificationType);
if (!preference) {
throw new NotFoundException('Notification preference not found');
}
preference.channels = channels;
await this.em.persistAndFlush(preference);
return preference;
}
async delete(restaurantId: string, notificationType: string): Promise<void> {
const preference = await this.findByRestaurantAndType(restaurantId, notificationType);
if (!preference) {
throw new NotFoundException('Notification preference not found');
}
await this.em.removeAndFlush(preference);
}
}
@@ -0,0 +1,63 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { NotificationQueueJob } from '../interfaces/notification-queue.interface';
import { NotificationChannel } from '../entities/notification.entity';
@Injectable()
export class NotificationQueueService {
private readonly logger = new Logger(NotificationQueueService.name);
constructor(@InjectQueue('notification-queue') private readonly notificationQueue: Queue) {}
async addNotification(job: NotificationQueueJob): Promise<void> {
try {
const jobId = job.idempotencyKey || `${job.restaurantId}-${job.notificationType}-${job.channel}-${Date.now()}`;
await this.notificationQueue.add('send-notification', job, {
jobId,
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: {
age: 24 * 3600, // Keep completed jobs for 24 hours
count: 1000,
},
removeOnFail: {
age: 7 * 24 * 3600, // Keep failed jobs for 7 days
},
});
this.logger.log(`Notification job added to queue: ${jobId}`);
} catch (error) {
this.logger.error(`Failed to add notification to queue:`, error);
throw error;
}
}
async addBulkNotifications(jobs: NotificationQueueJob[]): Promise<void> {
try {
const queueJobs = jobs.map(job => ({
name: 'send-notification',
data: job,
opts: {
jobId: job.idempotencyKey || `${job.restaurantId}-${job.notificationType}-${job.channel}-${Date.now()}`,
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
},
}));
await this.notificationQueue.addBulk(queueJobs);
this.logger.log(`Added ${jobs.length} notification jobs to queue`);
} catch (error) {
this.logger.error(`Failed to add bulk notifications to queue:`, error);
throw error;
}
}
}
@@ -0,0 +1,190 @@
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql';
import { Notification, NotificationStatus, NotificationChannel } from '../entities/notification.entity';
import { NotificationPreferenceService } from './notification-preference.service';
import { NotificationQueueService } from './notification-queue.service';
import { NotificationQueueJob } from '../interfaces/notification-queue.interface';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { User } from '../../users/entities/user.entity';
export interface SendNotificationParams {
restaurantId: string;
userId?: string;
notificationType: string;
payload: {
title?: string;
message: string;
body?: string;
phoneNumber?: string;
pushToken?: string;
data?: Record<string, any>;
templateId?: string;
parameters?: Array<{ name: string; value: string }>;
};
idempotencyKey?: string;
}
@Injectable()
export class NotificationService {
private readonly logger = new Logger(NotificationService.name);
constructor(
private readonly em: EntityManager,
private readonly preferenceService: NotificationPreferenceService,
private readonly queueService: NotificationQueueService,
) {}
async sendNotification(params: SendNotificationParams): Promise<Notification[]> {
const { restaurantId, userId, notificationType, payload, idempotencyKey } = params;
// Verify restaurant exists
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
if (!restaurant) {
throw new NotFoundException('Restaurant not found');
}
// Get notification preferences
const preference = await this.preferenceService.findByRestaurantAndType(restaurantId, notificationType);
if (!preference || !preference.enabled) {
this.logger.warn(
`Notification preference not found or disabled for restaurant ${restaurantId}, type ${notificationType}`,
);
return [];
}
// Determine which channels to use
const channels: NotificationChannel[] = [];
if (preference.channels.sms && payload.phoneNumber) {
channels.push(NotificationChannel.SMS);
}
if (preference.channels.push && payload.pushToken) {
channels.push(NotificationChannel.PUSH);
}
if (channels.length === 0) {
this.logger.warn(`No enabled channels for notification type ${notificationType}`);
return [];
}
// Create notification records and queue jobs
const notifications: Notification[] = [];
const jobs: NotificationQueueJob[] = [];
for (const channel of channels) {
const channelIdempotencyKey = idempotencyKey
? `${idempotencyKey}-${channel}`
: `${restaurantId}-${notificationType}-${channel}-${Date.now()}`;
// Create notification record
const user = userId ? await this.em.findOne(User, { id: userId }) : undefined;
const notification = this.em.create(Notification, {
restaurant,
user,
notificationType,
channel,
payload,
status: NotificationStatus.PENDING,
idempotencyKey: channelIdempotencyKey,
attemptCount: 0,
});
await this.em.persistAndFlush(notification);
notifications.push(notification);
// Add to queue
jobs.push({
restaurantId,
userId,
notificationType,
channel,
payload,
idempotencyKey: channelIdempotencyKey,
notificationId: notification.id,
});
}
// Add all jobs to queue
if (jobs.length === 1) {
await this.queueService.addNotification(jobs[0]);
} else {
await this.queueService.addBulkNotifications(jobs);
}
this.logger.log(
`Queued ${notifications.length} notification(s) for restaurant ${restaurantId}, type ${notificationType}`,
);
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(restaurantId: string, limit = 50): Promise<Notification[]> {
return this.em.find(
Notification,
{ restaurant: { id: restaurantId } },
{
orderBy: { createdAt: 'DESC' },
limit,
populate: ['user'],
},
);
}
async findByUserId(userId: string, limit = 50): Promise<Notification[]> {
return this.em.find(
Notification,
{ user: { id: userId } },
{
orderBy: { createdAt: 'DESC' },
limit,
populate: ['restaurant'],
},
);
}
async findByRestaurantAndType(restaurantId: string, notificationType: string, limit = 50): Promise<Notification[]> {
return this.em.find(
Notification,
{
restaurant: { id: restaurantId },
notificationType,
},
{
orderBy: { createdAt: 'DESC' },
limit,
populate: ['user'],
},
);
}
async retryFailedNotification(notificationId: string): Promise<void> {
const notification = await this.findOne(notificationId);
if (notification.status !== NotificationStatus.FAILED) {
throw new BadRequestException('Only failed notifications can be retried');
}
// Reset status and add to queue
notification.status = NotificationStatus.PENDING;
await this.em.persistAndFlush(notification);
await this.queueService.addNotification({
restaurantId: notification.restaurant.id,
notificationType: notification.notificationType,
channel: notification.channel,
payload: notification.payload,
idempotencyKey: notification.idempotencyKey,
notificationId: notification.id,
});
this.logger.log(`Retry queued for notification ${notificationId}`);
}
}
@@ -0,0 +1,148 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as admin from 'firebase-admin';
export interface PushNotificationPayload {
title: string;
body: string;
data?: Record<string, any>;
token?: string;
tokens?: string[];
}
export interface PushNotificationResponse {
success: boolean;
messageId?: string;
error?: string;
}
@Injectable()
export class PushNotificationService {
private readonly logger = new Logger(PushNotificationService.name);
private firebaseApp: admin.app.App | null = null;
constructor(private readonly configService: ConfigService) {
this.initializeFirebase();
}
private initializeFirebase() {
try {
const firebaseConfig = this.configService.get<string>('FIREBASE_SERVICE_ACCOUNT');
if (!firebaseConfig) {
this.logger.warn('Firebase service account not configured. Push notifications will be disabled.');
return;
}
// Parse the service account JSON
const serviceAccount = JSON.parse(firebaseConfig);
if (!this.firebaseApp) {
this.firebaseApp = admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
this.logger.log('Firebase initialized successfully');
}
} catch (error) {
this.logger.error('Failed to initialize Firebase:', error);
this.logger.warn('Push notifications will be disabled');
}
}
async sendToToken(payload: PushNotificationPayload): Promise<PushNotificationResponse> {
if (!this.firebaseApp || !payload.token) {
throw new HttpException('Push notification service not configured or token missing', HttpStatus.SERVICE_UNAVAILABLE);
}
try {
const message: admin.messaging.Message = {
notification: {
title: payload.title,
body: payload.body,
},
data: this.convertDataToString(payload.data || {}),
token: payload.token,
android: {
priority: 'high',
},
apns: {
headers: {
'apns-priority': '10',
},
},
};
const response = await admin.messaging().send(message);
this.logger.log(`Push notification sent successfully: ${response}`);
return {
success: true,
messageId: response,
};
} catch (error) {
this.logger.error(`Failed to send push notification: ${error}`);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
async sendToMultipleTokens(payload: PushNotificationPayload): Promise<PushNotificationResponse> {
if (!this.firebaseApp || !payload.tokens || payload.tokens.length === 0) {
throw new HttpException(
'Push notification service not configured or tokens missing',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
try {
const message: admin.messaging.MulticastMessage = {
notification: {
title: payload.title,
body: payload.body,
},
data: this.convertDataToString(payload.data || {}),
tokens: payload.tokens,
android: {
priority: 'high',
},
apns: {
headers: {
'apns-priority': '10',
},
},
};
const response = await admin.messaging().sendEachForMulticast(message);
this.logger.log(
`Push notifications sent: ${response.successCount} successful, ${response.failureCount} failed`,
);
return {
success: response.successCount > 0,
messageId: response.responses[0]?.messageId,
error: response.failureCount > 0 ? `${response.failureCount} notifications failed` : undefined,
};
} catch (error) {
this.logger.error(`Failed to send push notifications: ${error}`);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
private convertDataToString(data: Record<string, any>): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(data)) {
result[key] = typeof value === 'string' ? value : JSON.stringify(value);
}
return result;
}
isConfigured(): boolean {
return this.firebaseApp !== null;
}
}
@@ -0,0 +1,96 @@
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 }],
});
}
}