notifcation

This commit is contained in:
2025-12-09 10:19:28 +03:30
parent 44fd209c92
commit ead1e096a5
16 changed files with 510 additions and 606 deletions
@@ -0,0 +1,4 @@
export enum NotificationQueueNameEnum {
SMS = 'sms',
PUSH = 'push',
}
@@ -1,6 +1,8 @@
import { IsString, IsNotEmpty, IsBoolean, IsObject, IsOptional, ValidateNested } from 'class-validator'; import { IsString, IsNotEmpty, IsBoolean, IsOptional, ValidateNested } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { NotificationTitle } from '../entities/notification.entity';
import { Enum } from '@mikro-orm/core';
import { NotificationType } from '../interfaces/notification.interface';
class ChannelsDto { class ChannelsDto {
@ApiPropertyOptional({ description: 'Enable SMS channel' }) @ApiPropertyOptional({ description: 'Enable SMS channel' })
@@ -14,22 +16,16 @@ class ChannelsDto {
push?: boolean; push?: boolean;
} }
export class CreateNotificationPreferenceDto { export class CreatePreferenceDto {
@ApiProperty({ description: 'Notification type (e.g., ORDER_CONFIRMED, PAYMENT_FAILED)' }) @ApiProperty({ description: 'Notification type (e.g., ORDER_CONFIRMED, PAYMENT_FAILED)' })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
notificationType: string; @Enum(() => NotificationType)
notificationType!: NotificationType;
@ApiProperty({ description: 'Notification channels configuration', type: ChannelsDto }) @ApiProperty({ description: 'Notification channels configuration', type: ChannelsDto })
@ValidateNested() @ValidateNested()
@Type(() => ChannelsDto)
@IsObject()
@IsNotEmpty() @IsNotEmpty()
channels: ChannelsDto; @Enum(() => NotificationTitle)
title!: NotificationTitle;
@ApiPropertyOptional({ description: 'Whether the preference is enabled', default: true })
@IsBoolean()
@IsOptional()
enabled?: boolean;
} }
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreatePreferenceDto } from './create-preference.dto';
export class UpdatePreferenceDto extends PartialType(CreatePreferenceDto) {}
@@ -1,24 +1,18 @@
import { Entity, Property, ManyToOne, Unique } from '@mikro-orm/core'; import { Entity, Property, ManyToOne, Unique, Enum } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity'; import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity'; import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { NotificationTitle } from './notification.entity';
export interface NotificationChannels { import { NotificationType } from '../interfaces/notification.interface';
sms?: boolean;
push?: boolean;
}
@Entity({ tableName: 'notification_preferences' }) @Entity({ tableName: 'notification_preferences' })
@Unique({ properties: ['restaurant', 'notificationType'] }) @Unique({ properties: ['restaurant', 'title'] })
export class NotificationPreference extends BaseEntity { export class NotificationPreference extends BaseEntity {
@ManyToOne(() => Restaurant) @ManyToOne(() => Restaurant)
restaurant!: Restaurant; restaurant!: Restaurant;
@Property() @Property()
notificationType!: string; // e.g., ORDER_CONFIRMED, PAYMENT_FAILED title!: NotificationTitle;
@Property({ type: 'json' }) @Enum(() => NotificationType)
channels!: NotificationChannels; // { sms: true, push: true } notificationType!: NotificationType; //sms, push, email
@Property({ default: true })
enabled: boolean = true;
} }
@@ -3,16 +3,10 @@ import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity'; import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { User } from '../../users/entities/user.entity'; import { User } from '../../users/entities/user.entity';
export enum NotificationStatus { export enum NotificationTitle {
PENDING = 'pending', ORDER_CREATED = 'order.created',
SENT = 'sent', REVIEW_CREATED = 'review.created',
FAILED = 'failed', ORDER_STATUS_CHANGED = 'order.status.changed',
SKIPPED = 'skipped',
}
export enum NotificationChannel {
SMS = 'sms',
PUSH = 'push',
} }
@Entity({ tableName: 'notifications' }) @Entity({ tableName: 'notifications' })
@@ -23,24 +17,11 @@ export class Notification extends BaseEntity {
@ManyToOne(() => User, { nullable: true }) @ManyToOne(() => User, { nullable: true })
user?: User; user?: User;
@Enum(() => NotificationTitle)
title!: NotificationTitle;
@Property() @Property()
notificationType!: string; // e.g., ORDER_CONFIRMED, PAYMENT_FAILED content!: string;
@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 }) @Property({ nullable: true, unique: true })
idempotencyKey?: string; idempotencyKey?: string;
@@ -1,13 +1,14 @@
import { NotificationChannel } from '../entities/notification.entity'; import type { NotificationType } from './notification.interface';
import type { NotificationTitle } from '../entities/notification.entity';
export interface NotificationQueueJob { export interface NotificationQueueJob {
restaurantId: string; restaurantId: string;
userId?: string; userId?: string;
notificationType: string; title: NotificationTitle;
channel: NotificationChannel; content: string;
payload: Record<string, any>;
idempotencyKey?: string; idempotencyKey?: string;
notificationId?: string; // For retries notificationId?: string; // For retries
notificationType: NotificationType;
} }
export interface NotificationQueueJobResult { export interface NotificationQueueJobResult {
@@ -16,4 +17,3 @@ export interface NotificationQueueJobResult {
providerResponse?: Record<string, any>; providerResponse?: Record<string, any>;
error?: string; error?: string;
} }
@@ -0,0 +1,6 @@
export enum NotificationType {
NONE = 'none',
SMS = 'sms',
PUSH = 'push',
Both = 'both',
}
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { NotificationService } from '../services/notification.service'; import { NotificationService } from '../services/notification.service';
import { NotificationTitle } from '../entities/notification.entity';
import { OrderCreatedEvent, ReviewCreatedEvent, OrderStatusChangedEvent } from '../events/notification.events'; import { OrderCreatedEvent, ReviewCreatedEvent, OrderStatusChangedEvent } from '../events/notification.events';
@Injectable() @Injectable()
@@ -17,12 +18,8 @@ export class NotificationListeners {
await this.notificationService.sendNotification({ await this.notificationService.sendNotification({
restaurantId: event.restaurantId, restaurantId: event.restaurantId,
userId: event.userId, userId: event.userId,
notificationType: 'order.created', title: NotificationTitle.ORDER_CREATED,
payload: { content: `Your order #${event.orderId} has been confirmed`,
title: 'Order Confirmed',
message: `Your order #${event.orderId} has been confirmed`,
data: { orderId: event.orderId },
},
}); });
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
@@ -40,12 +37,8 @@ export class NotificationListeners {
await this.notificationService.sendNotification({ await this.notificationService.sendNotification({
restaurantId: event.restaurantId, restaurantId: event.restaurantId,
userId: event.userId, userId: event.userId,
notificationType: 'review.created', title: NotificationTitle.REVIEW_CREATED,
payload: { content: 'Thank you for your review!',
title: 'Review Received',
message: 'Thank you for your review!',
data: { reviewId: event.reviewId },
},
}); });
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
@@ -63,16 +56,8 @@ export class NotificationListeners {
await this.notificationService.sendNotification({ await this.notificationService.sendNotification({
restaurantId: event.restaurantId, restaurantId: event.restaurantId,
userId: event.userId, userId: event.userId,
notificationType: 'order.status.changed', title: NotificationTitle.ORDER_STATUS_CHANGED,
payload: { content: `Your order #${event.orderId} is now ${event.newStatus}`,
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) { } catch (error) {
this.logger.error( this.logger.error(
@@ -2,12 +2,12 @@ import { Controller, Post, Get, Body, Param, UseGuards, Query, Patch, Delete } f
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger';
import { NotificationService } from './services/notification.service'; import { NotificationService } from './services/notification.service';
import { NotificationPreferenceService } from './services/notification-preference.service'; import { NotificationPreferenceService } from './services/notification-preference.service';
import { SendNotificationDto } from './dto/send-notification.dto'; import { CreatePreferenceDto } from './dto/create-preference.dto';
import { CreateNotificationPreferenceDto } from './dto/create-preference.dto';
import { AuthGuard } from '../auth/guards/auth.guard'; import { AuthGuard } from '../auth/guards/auth.guard';
import { UserId } from '../../common/decorators/user-id.decorator'; import { UserId } from '../../common/decorators/user-id.decorator';
import { AdminAuthGuard } from '../auth/guards/adminAuth.guard'; import { AdminAuthGuard } from '../auth/guards/adminAuth.guard';
import { RestId } from '../../common/decorators/rest-id.decorator'; import { RestId } from '../../common/decorators/rest-id.decorator';
import { UpdatePreferenceDto } from './dto/update-preference.dto';
@ApiTags('notifications') @ApiTags('notifications')
@Controller() @Controller()
@@ -17,84 +17,92 @@ export class NotificationsController {
private readonly preferenceService: NotificationPreferenceService, 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) @UseGuards(AuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@Get('public/notifications') @Get('public/notifications')
@ApiOperation({ summary: 'Get user notifications' }) @ApiOperation({ summary: 'Get user restaurant notifications' })
@ApiQuery({ name: 'limit', required: false, type: Number }) @ApiQuery({ name: 'limit', required: false, type: Number })
async getUserNotifications(@UserId() userId: string, @Query('limit') limit?: number) { async getUserNotifications(@UserId() userId: string, @RestId() restaurantId: string, @Query('limit') limit?: number) {
return this.notificationService.findByUserId(userId, limit ? parseInt(limit.toString(), 10) : 50); return await this.notificationService.findByUserAndRestaurant(
userId,
restaurantId,
limit ? parseInt(limit.toString(), 10) : 50,
);
} }
@UseGuards(AuthGuard) @UseGuards(AdminAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@Get('public/notifications/:id') @Delete('admin/notification/:id')
@ApiOperation({ summary: 'Get a notification by ID' }) @ApiOperation({ summary: 'Delete a notification ' })
@ApiParam({ name: 'id', description: 'Notification ID' }) @ApiParam({ name: 'id', description: 'Notification ID' })
async getUserNotification(@Param('id') id: string) { async deleteNotification(@RestId() restaurantId: string, @Param('id') id: string) {
return this.notificationService.findOne(id); await this.notificationService.removeNotification(id, restaurantId);
return { message: 'Notification deleted successfully' };
} }
// @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);
// }
// @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' };
// }
// Preference endpoints // Preference endpoints
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@Post('admin/notification-preferences') @Post('admin/notification-preferences')
@ApiOperation({ summary: 'Create or update notification preference' }) @ApiOperation({ summary: 'Create notification preference' })
async createPreference(@RestId() restaurantId: string, @Body() dto: CreateNotificationPreferenceDto) { createPreference(@RestId() restaurantId: string, @Body() dto: CreatePreferenceDto) {
return this.preferenceService.createOrUpdate( return this.preferenceService.createOrUpdate(restaurantId, dto);
restaurantId,
dto.notificationType,
dto.channels,
dto.enabled ?? true,
);
} }
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
@@ -105,48 +113,48 @@ export class NotificationsController {
return this.preferenceService.findByRestaurant(restaurantId); return this.preferenceService.findByRestaurant(restaurantId);
} }
@UseGuards(AdminAuthGuard) // @UseGuards(AdminAuthGuard)
@ApiBearerAuth() // @ApiBearerAuth()
@Get('admin/notification-preferences/:type') // @Get('admin/notification-preferences/:type')
@ApiOperation({ summary: 'Get a specific notification preference' }) // @ApiOperation({ summary: 'Get a specific notification preference' })
@ApiParam({ name: 'type', description: 'Notification type' }) // @ApiParam({ name: 'type', description: 'Notification type' })
async getPreference(@RestId() restaurantId: string, @Param('type') notificationType: string) { // async getPreference(@RestId() restaurantId: string, @Param('type') notificationType: string) {
return this.preferenceService.findByRestaurantAndType(restaurantId, notificationType); // 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) @UseGuards(AdminAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@Patch('admin/notification-preferences/:type/enabled') @Patch('admin/notification-preferences/:id')
@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' }) @ApiOperation({ summary: 'Update notification channels' })
@ApiParam({ name: 'type', description: 'Notification type' }) @ApiParam({ name: 'id', description: 'Notification preference ID' })
async updatePreferenceChannels( async updatePreference(
@RestId() restaurantId: string, @RestId() restaurantId: string,
@Param('type') notificationType: string, @Param('id') preferenceId: string,
@Body('channels') channels: { sms?: boolean; push?: boolean }, @Body() dto: UpdatePreferenceDto,
) { ) {
return this.preferenceService.updateChannels(restaurantId, notificationType, channels); return this.preferenceService.updatePreference(preferenceId, restaurantId, dto);
} }
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@Delete('admin/notification-preferences/:type') @Delete('admin/notification-preferences/:id')
@ApiOperation({ summary: 'Delete a notification preference' }) @ApiOperation({ summary: 'Delete a notification preference' })
@ApiParam({ name: 'type', description: 'Notification type' }) @ApiParam({ name: 'id', description: 'Notification preference ID' })
async deletePreference(@RestId() restaurantId: string, @Param('type') notificationType: string) { async deletePreference(@RestId() restaurantId: string, @Param('id') preferenceId: string) {
await this.preferenceService.delete(restaurantId, notificationType); await this.preferenceService.delete(restaurantId, preferenceId);
return { message: 'Preference deleted successfully' }; return { message: 'Preference deleted successfully' };
} }
} }
@@ -9,23 +9,22 @@ import { NotificationPreferenceService } from './services/notification-preferenc
import { NotificationQueueService } from './services/notification-queue.service'; import { NotificationQueueService } from './services/notification-queue.service';
import { PushNotificationService } from './services/push-notification.service'; import { PushNotificationService } from './services/push-notification.service';
import { SmsNotificationService } from './services/sms-notification.service'; import { SmsNotificationService } from './services/sms-notification.service';
import { NotificationProcessor } from './processors/notification.processor'; import { SmsProcessor } from './processors/sms.processor';
import { PushProcessor } from './processors/push.processor';
import { NotificationsController } from './notifications.controller'; import { NotificationsController } from './notifications.controller';
import { User } from '../users/entities/user.entity'; import { User } from '../users/entities/user.entity';
import { Restaurant } from '../restaurants/entities/restaurant.entity'; import { Restaurant } from '../restaurants/entities/restaurant.entity';
import { NotificationListeners } from './listeners/notification.listeners'; import { NotificationListeners } from './listeners/notification.listeners';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NotificationQueueNameEnum } from './constants/queue';
@Module({ @Module({
imports: [ imports: [
AuthModule, AuthModule,
JwtModule, JwtModule,
MikroOrmModule.forFeature([Notification, NotificationPreference, User, Restaurant]), MikroOrmModule.forFeature([Notification, NotificationPreference, User, Restaurant]),
BullModule.registerQueue({ BullModule.registerQueue({ name: NotificationQueueNameEnum.SMS }, { name: NotificationQueueNameEnum.PUSH }),
name: 'notification-queue',
}),
BullModule.forRootAsync({ BullModule.forRootAsync({
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({
@@ -44,7 +43,8 @@ import { ConfigService } from '@nestjs/config';
NotificationQueueService, NotificationQueueService,
PushNotificationService, PushNotificationService,
SmsNotificationService, SmsNotificationService,
NotificationProcessor, PushProcessor,
SmsProcessor,
NotificationListeners, NotificationListeners,
], ],
exports: [ exports: [
@@ -1,248 +0,0 @@
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,99 @@
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 } from '../entities/notification.entity';
import { NotificationQueueJob, NotificationQueueJobResult } from '../interfaces/notification-queue.interface';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { User } from '../../users/entities/user.entity';
import { NotificationQueueNameEnum } from '../constants/queue';
@Processor(NotificationQueueNameEnum.PUSH)
export class PushProcessor extends WorkerHost {
private readonly logger = new Logger(PushProcessor.name);
constructor(
@InjectRepository(Notification)
private readonly notificationRepository: EntityRepository<Notification>,
private readonly em: EntityManager,
) {
super();
}
async process(job: Job<NotificationQueueJob>): Promise<NotificationQueueJobResult> {
const { restaurantId, userId, title, content, idempotencyKey, notificationId } = job.data;
this.logger.log(`Processing notification job: ${job.id} - Title: ${title}, 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.sentAt) {
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,
title,
content,
idempotencyKey,
});
await this.em.persistAndFlush(notification);
}
}
// Mark notification as sent
notification.sentAt = new Date();
await this.em.persistAndFlush(notification);
this.logger.log(`Notification ${notification.id} processed successfully`);
return {
success: true,
notificationId: notification.id,
};
} catch (error) {
this.logger.error(`Error processing notification job ${job.id}:`, error);
throw error;
}
}
@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,99 @@
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 } from '../entities/notification.entity';
import { NotificationQueueJob, NotificationQueueJobResult } from '../interfaces/notification-queue.interface';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { User } from '../../users/entities/user.entity';
import { NotificationQueueNameEnum } from '../constants/queue';
@Processor(NotificationQueueNameEnum.SMS)
export class SmsProcessor extends WorkerHost {
private readonly logger = new Logger(SmsProcessor.name);
constructor(
@InjectRepository(Notification)
private readonly notificationRepository: EntityRepository<Notification>,
private readonly em: EntityManager,
) {
super();
}
async process(job: Job<NotificationQueueJob>): Promise<NotificationQueueJobResult> {
const { restaurantId, userId, title, content, idempotencyKey, notificationId } = job.data;
this.logger.log(`Processing notification job: ${job.id} - Title: ${title}, 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.sentAt) {
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,
title,
content,
idempotencyKey,
});
await this.em.persistAndFlush(notification);
}
}
// Mark notification as sent
notification.sentAt = new Date();
await this.em.persistAndFlush(notification);
this.logger.log(`Notification ${notification.id} processed successfully`);
return {
success: true,
notificationId: notification.id,
};
} catch (error) {
this.logger.error(`Error processing notification job ${job.id}:`, error);
throw error;
}
}
@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);
}
}
@@ -1,39 +1,26 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql'; import { EntityManager } from '@mikro-orm/postgresql';
import { NotificationPreference, NotificationChannels } from '../entities/notification-preference.entity'; import { NotificationPreference } from '../entities/notification-preference.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity'; import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { CreatePreferenceDto } from '../dto/create-preference.dto';
import { UpdatePreferenceDto } from '../dto/update-preference.dto';
import { NotificationTitle } from '../entities/notification.entity';
@Injectable() @Injectable()
export class NotificationPreferenceService { export class NotificationPreferenceService {
constructor(private readonly em: EntityManager) {} constructor(private readonly em: EntityManager) {}
async createOrUpdate( async createOrUpdate(restaurantId: string, dto: CreatePreferenceDto): Promise<NotificationPreference> {
restaurantId: string,
notificationType: string,
channels: NotificationChannels,
enabled: boolean = true,
): Promise<NotificationPreference> {
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId }); const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
if (!restaurant) { if (!restaurant) {
throw new NotFoundException('Restaurant not found'); throw new NotFoundException('Restaurant not found');
} }
let preference = await this.em.findOne(NotificationPreference, { const preference = this.em.create(NotificationPreference, {
restaurant: { id: restaurantId },
notificationType,
});
if (preference) {
preference.channels = channels;
preference.enabled = enabled;
} else {
preference = this.em.create(NotificationPreference, {
restaurant, restaurant,
notificationType, notificationType: dto.notificationType,
channels, title: dto.title,
enabled,
}); });
}
await this.em.persistAndFlush(preference); await this.em.persistAndFlush(preference);
return preference; return preference;
@@ -47,47 +34,55 @@ export class NotificationPreferenceService {
async findByRestaurantAndType( async findByRestaurantAndType(
restaurantId: string, restaurantId: string,
notificationType: string, title: NotificationTitle,
): Promise<NotificationPreference | null> { ): Promise<NotificationPreference | null> {
return this.em.findOne(NotificationPreference, { return this.em.findOne(NotificationPreference, {
restaurant: { id: restaurantId }, restaurant: { id: restaurantId },
notificationType, title,
}); });
} }
async updateEnabled(restaurantId: string, notificationType: string, enabled: boolean): Promise<NotificationPreference> { // async updateEnabled(
const preference = await this.findByRestaurantAndType(restaurantId, notificationType); // restaurantId: string,
if (!preference) { // notificationType: string,
throw new NotFoundException('Notification preference not found'); // enabled: boolean,
} // ): Promise<NotificationPreference> {
// const preference = await this.findByRestaurantAndType(restaurantId, notificationType);
// if (!preference) {
// throw new NotFoundException('Notification preference not found');
// }
preference.enabled = enabled; // preference.enabled = enabled;
await this.em.persistAndFlush(preference); // await this.em.persistAndFlush(preference);
return preference; // return preference;
} // }
async updateChannels( async updatePreference(
preferenceId: string,
restaurantId: string, restaurantId: string,
notificationType: string, dto: UpdatePreferenceDto,
channels: NotificationChannels,
): Promise<NotificationPreference> { ): Promise<NotificationPreference> {
const preference = await this.findByRestaurantAndType(restaurantId, notificationType); const preference = await this.em.findOne(NotificationPreference, {
id: preferenceId,
restaurant: { id: restaurantId },
});
if (!preference) { if (!preference) {
throw new NotFoundException('Notification preference not found'); throw new NotFoundException('Notification preference not found');
} }
preference.channels = channels; this.em.assign(preference, dto);
await this.em.persistAndFlush(preference); await this.em.persistAndFlush(preference);
return preference; return preference;
} }
async delete(restaurantId: string, notificationType: string): Promise<void> { async delete(restaurantId: string, preferenceId: string): Promise<void> {
const preference = await this.findByRestaurantAndType(restaurantId, notificationType); const preference = await this.em.findOne(NotificationPreference, {
restaurant: { id: restaurantId },
id: preferenceId,
});
if (!preference) { if (!preference) {
throw new NotFoundException('Notification preference not found'); throw new NotFoundException('Notification preference not found');
} }
await this.em.removeAndFlush(preference); await this.em.removeAndFlush(preference);
} }
} }
@@ -1,20 +1,23 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { NotificationQueueNameEnum } from '../constants/queue';
import { NotificationQueueJob } from '../interfaces/notification-queue.interface'; import { NotificationQueueJob } from '../interfaces/notification-queue.interface';
import { NotificationChannel } from '../entities/notification.entity';
@Injectable() @Injectable()
export class NotificationQueueService { export class NotificationQueueService {
private readonly logger = new Logger(NotificationQueueService.name); private readonly logger = new Logger(NotificationQueueService.name);
constructor(@InjectQueue('notification-queue') private readonly notificationQueue: Queue) {} constructor(
@InjectQueue(NotificationQueueNameEnum.SMS) private readonly smsQueue: Queue,
@InjectQueue(NotificationQueueNameEnum.PUSH) private readonly pushQueue: Queue,
) {}
async addNotification(job: NotificationQueueJob): Promise<void> { async addSmsNotification(job: NotificationQueueJob): Promise<void> {
try { try {
const jobId = job.idempotencyKey || `${job.restaurantId}-${job.notificationType}-${job.channel}-${Date.now()}`; const jobId = job.idempotencyKey || `${job.restaurantId}-${job.title}-${Date.now()}`;
await this.notificationQueue.add('send-notification', job, { await this.smsQueue.add('sms-notification', job, {
jobId, jobId,
attempts: 3, attempts: 3,
backoff: { backoff: {
@@ -30,34 +33,60 @@ export class NotificationQueueService {
}, },
}); });
this.logger.log(`Notification job added to queue: ${jobId}`); this.logger.log(`SMS notification job added to queue: ${jobId}`);
} catch (error) { } catch (error) {
this.logger.error(`Failed to add notification to queue:`, error); this.logger.error(`Failed to add SMS notification to queue:`, error);
throw error; throw error;
} }
} }
async addBulkNotifications(jobs: NotificationQueueJob[]): Promise<void> { async addPushNotification(job: NotificationQueueJob): Promise<void> {
try { try {
const queueJobs = jobs.map(job => ({ const jobId = job.idempotencyKey || `${job.restaurantId}-${job.title}-${Date.now()}`;
name: 'send-notification',
data: job, await this.pushQueue.add('push-notification', job, {
opts: { jobId,
jobId: job.idempotencyKey || `${job.restaurantId}-${job.notificationType}-${job.channel}-${Date.now()}`,
attempts: 3, attempts: 3,
backoff: { backoff: {
type: 'exponential', type: 'exponential',
delay: 2000, 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
},
});
await this.notificationQueue.addBulk(queueJobs); this.logger.log(`Push notification job added to queue: ${jobId}`);
this.logger.log(`Added ${jobs.length} notification jobs to queue`);
} catch (error) { } catch (error) {
this.logger.error(`Failed to add bulk notifications to queue:`, error); this.logger.error(`Failed to add push notification to queue:`, error);
throw 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.title}-${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;
// }
// }
}
@@ -1,26 +1,18 @@
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql'; import { EntityManager } from '@mikro-orm/postgresql';
import { Notification, NotificationStatus, NotificationChannel } from '../entities/notification.entity'; import { Notification, NotificationTitle } from '../entities/notification.entity';
import { NotificationPreferenceService } from './notification-preference.service'; import { NotificationPreferenceService } from './notification-preference.service';
import { NotificationQueueService } from './notification-queue.service'; import { NotificationQueueService } from './notification-queue.service';
import { NotificationQueueJob } from '../interfaces/notification-queue.interface'; import { NotificationQueueJob } from '../interfaces/notification-queue.interface';
import { Restaurant } from '../../restaurants/entities/restaurant.entity'; import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { User } from '../../users/entities/user.entity'; import { User } from '../../users/entities/user.entity';
import { NotificationType } from '../interfaces/notification.interface';
export interface SendNotificationParams { export interface SendNotificationParams {
restaurantId: string; restaurantId: string;
userId?: string; userId?: string;
notificationType: string; title: NotificationTitle;
payload: { content: string;
title?: string;
message: string;
body?: string;
phoneNumber?: string;
pushToken?: string;
data?: Record<string, any>;
templateId?: string;
parameters?: Array<{ name: string; value: string }>;
};
idempotencyKey?: string; idempotencyKey?: string;
} }
@@ -34,8 +26,8 @@ export class NotificationService {
private readonly queueService: NotificationQueueService, private readonly queueService: NotificationQueueService,
) {} ) {}
async sendNotification(params: SendNotificationParams): Promise<Notification[]> { async sendNotification(params: SendNotificationParams): Promise<Notification> {
const { restaurantId, userId, notificationType, payload, idempotencyKey } = params; const { restaurantId, userId, title, content, idempotencyKey } = params;
// Verify restaurant exists // Verify restaurant exists
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId }); const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
@@ -44,78 +36,54 @@ export class NotificationService {
} }
// Get notification preferences // Get notification preferences
const preference = await this.preferenceService.findByRestaurantAndType(restaurantId, notificationType); const preference = await this.preferenceService.findByRestaurantAndType(restaurantId, title);
if (!preference || !preference.enabled) { if (!preference) {
this.logger.warn( this.logger.warn(`Notification preference not found or disabled for restaurant ${restaurantId}, type ${title}`);
`Notification preference not found or disabled for restaurant ${restaurantId}, type ${notificationType}`, throw new NotFoundException(`Notification preference not found or disabled for type ${title}`);
);
return [];
} }
// Determine which channels to use // Generate idempotency key if not provided
const channels: NotificationChannel[] = []; const finalIdempotencyKey = idempotencyKey || `${restaurantId}-${title}-${Date.now()}`;
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 // Create notification record
const user = userId ? await this.em.findOne(User, { id: userId }) : undefined; const user = userId ? await this.em.findOne(User, { id: userId }) : undefined;
const notification = this.em.create(Notification, { const notification = this.em.create(Notification, {
restaurant, restaurant,
user, user,
notificationType, title,
channel, content,
payload, idempotencyKey: finalIdempotencyKey,
status: NotificationStatus.PENDING,
idempotencyKey: channelIdempotencyKey,
attemptCount: 0,
}); });
await this.em.persistAndFlush(notification); await this.em.persistAndFlush(notification);
notifications.push(notification);
if (preference.notificationType === NotificationType.NONE) {
this.logger.warn(`Notification type is NONE for restaurant ${restaurantId}, title ${title}`);
return notification;
}
// Add to queue // Add to queue
jobs.push({ const job: NotificationQueueJob = {
restaurantId, restaurantId,
userId, userId,
notificationType, title,
channel, content,
payload, idempotencyKey: finalIdempotencyKey,
idempotencyKey: channelIdempotencyKey,
notificationId: notification.id, notificationId: notification.id,
}); notificationType: preference.notificationType,
};
if (preference.notificationType === NotificationType.SMS) {
await this.queueService.addSmsNotification(job);
} else if (preference.notificationType === NotificationType.PUSH) {
await this.queueService.addPushNotification(job);
} else if (preference.notificationType === NotificationType.Both) {
await this.queueService.addSmsNotification(job);
await this.queueService.addPushNotification(job);
} }
// Add all jobs to queue this.logger.log(`Queued notification for restaurant ${restaurantId}, title ${title}`);
if (jobs.length === 1) {
await this.queueService.addNotification(jobs[0]);
} else {
await this.queueService.addBulkNotifications(jobs);
}
this.logger.log( return notification;
`Queued ${notifications.length} notification(s) for restaurant ${restaurantId}, type ${notificationType}`,
);
return notifications;
} }
async findOne(id: string): Promise<Notification> { async findOne(id: string): Promise<Notification> {
@@ -138,24 +106,31 @@ export class NotificationService {
); );
} }
async findByUserId(userId: string, limit = 50): Promise<Notification[]> { async findByUserAndRestaurant(userId: string, restaurantId: string, limit = 50): Promise<Notification[]> {
return this.em.find( return this.em.find(
Notification, Notification,
{ user: { id: userId } }, { user: { id: userId }, restaurant: { id: restaurantId } },
{ {
orderBy: { createdAt: 'DESC' }, orderBy: { createdAt: 'DESC' },
limit, limit,
populate: ['restaurant'],
}, },
); );
} }
async findByRestaurantAndType(restaurantId: string, notificationType: string, limit = 50): Promise<Notification[]> { async removeNotification(id: string, restaurantId: string): Promise<void> {
const notification = await this.em.findOne(Notification, { id, restaurant: { id: restaurantId } });
if (!notification) {
throw new NotFoundException('Notification not found');
}
await this.em.removeAndFlush(notification as any);
}
async findByRestaurantAndType(restaurantId: string, title: NotificationTitle, limit = 50): Promise<Notification[]> {
return this.em.find( return this.em.find(
Notification, Notification,
{ {
restaurant: { id: restaurantId }, restaurant: { id: restaurantId },
notificationType, title,
}, },
{ {
orderBy: { createdAt: 'DESC' }, orderBy: { createdAt: 'DESC' },
@@ -164,27 +139,4 @@ export class NotificationService {
}, },
); );
} }
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}`);
}
} }