notifcation
This commit is contained in:
@@ -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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user