notification
This commit is contained in:
Generated
+1226
-69
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -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
@@ -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 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user