remove unused modules

This commit is contained in:
2026-01-07 12:24:55 +03:30
parent f2284c103d
commit 560b2983f3
150 changed files with 1853 additions and 96521 deletions
@@ -0,0 +1,5 @@
export enum NotificationQueueNameEnum {
SMS = 'sms',
PUSH = 'push',
IN_APP = 'in-app',
}
@@ -0,0 +1,196 @@
import { Controller, Get, Body, Param, UseGuards, Query, Patch, Put, Post } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiHeader } from '@nestjs/swagger';
import { NotificationService } from '../services/notification.service';
import { NotificationPreferenceService } from '../services/notification-preference.service';
import { AuthGuard } from '../../auth/guards/auth.guard';
import { UserId } from '../../../common/decorators/user-id.decorator';
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
import { RestId } from '../../../common/decorators/rest-id.decorator';
import { UpdatePreferenceDto } from '../dto/update-preference.dto';
import { CreatePreferenceDto } from '../dto/create-preference.dto';
import { AdminId } from 'src/common/decorators/admin-id.decorator';
import { API_HEADER_SLUG } from 'src/common/constants/index';
import { NotificationMessage } from 'src/common/enums/message.enum';
import { SuperAdminAuthGuard } from 'src/modules/auth/guards/superAdminAuth.guard';
import { Permissions } from 'src/common/decorators/permissions.decorator';
import { Permission } from 'src/common/enums/permission.enum';
@ApiTags('notifications')
@Controller()
export class NotificationsController {
constructor(
private readonly notificationService: NotificationService,
private readonly preferenceService: NotificationPreferenceService,
) { }
@UseGuards(AuthGuard)
@ApiBearerAuth()
@Get('public/notifications')
@ApiOperation({ summary: 'Get user restaurant notifications' })
@ApiHeader(API_HEADER_SLUG)
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of items to return (default: 50)' })
@ApiQuery({
name: 'cursor',
required: false,
type: String,
description: 'Cursor for pagination (ID of the last item from previous page)',
})
@ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' })
async getUserNotifications(
@UserId() userId: string,
@RestId() restaurantId: string,
@Query('limit') limit?: number,
@Query('cursor') cursor?: string,
@Query('status') status?: 'seen' | 'unseen',
) {
return await this.notificationService.findByUserAndRestaurant(
userId,
restaurantId,
limit ? parseInt(limit.toString(), 10) : 50,
cursor,
status,
);
}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@Get('public/notifications/unseen-count')
@ApiOperation({ summary: 'Get unseen notifications count for user' })
@ApiHeader(API_HEADER_SLUG)
async getUserUnseenCount(@UserId() userId: string, @RestId() restaurantId: string) {
const count = await this.notificationService.countUnseenByUserAndRestaurant(userId, restaurantId);
return { count };
}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@Put('public/notifications/:id')
@ApiOperation({ summary: 'Read a notification ' })
@ApiParam({ name: 'id', description: 'Notification ID' })
async readNotificationUser(@RestId() restaurantId: string, @Param('id') id: string, @UserId() userId: string) {
await this.notificationService.readNotificationAsUser(id, userId, restaurantId);
return { message: NotificationMessage.READ_SUCCESS };
}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@Put('public/notifications/read/all')
@ApiOperation({ summary: 'Read all notification ' })
async readAllNotificationUser(@RestId() restaurantId: string, @UserId() userId: string) {
await this.notificationService.readAllNotifsAsUser(userId, restaurantId);
return { message: NotificationMessage.READ_SUCCESS };
}
/* ***************** Admin Endpoints ***************** */
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Get('admin/notifications')
@ApiOperation({ summary: 'Get Admin notifications' })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({
name: 'cursor',
required: false,
type: String,
description: 'Cursor for pagination (ID of the last item from previous page)',
})
@ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' })
async getAdminNotifications(
@AdminId() adminId: string,
@RestId() restaurantId: string,
@Query('limit') limit?: number,
@Query('cursor') cursor?: string,
@Query('status') status?: 'seen' | 'unseen',
) {
const parsedLimit = limit ? parseInt(limit.toString(), 10) : 50;
return await this.notificationService.findByRestaurant(restaurantId, adminId, parsedLimit, cursor, status);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Get('admin/notifications/unseen-count')
@ApiOperation({ summary: 'Get unseen notifications count for admin' })
async getAdminUnseenCount(@AdminId() adminId: string, @RestId() restaurantId: string) {
const count = await this.notificationService.countUnseenByRestaurant(adminId, restaurantId);
return { count };
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Put('admin/notifications/:id')
@ApiOperation({ summary: 'Read a notification ' })
@ApiParam({ name: 'id', description: 'Notification ID' })
async readNotificationAdmin(@RestId() restaurantId: string, @AdminId() adminId: string, @Param('id') id: string) {
await this.notificationService.readNotificationAdmin(id, adminId, restaurantId);
return { message: NotificationMessage.READ_SUCCESS };
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Put('admin/notifications/read/all')
@ApiOperation({ summary: 'Read all notification ' })
async readAllNotificationAdmin(@RestId() restaurantId: string, @AdminId() adminId: string, @Param('id') id: string) {
await this.notificationService.readAllNotifsAsAdmin(adminId, restaurantId);
return { message: NotificationMessage.READ_SUCCESS };
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_SETTINGS)
@Get('admin/notification-preferences')
@ApiOperation({ summary: 'Get all notification preferences for a restaurant' })
async getPreferences(@RestId() restaurantId: string) {
return this.preferenceService.findByRestaurant(restaurantId);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_SETTINGS)
@Patch('admin/notification-preferences/:id')
@ApiOperation({ summary: 'Update notification channels' })
@ApiParam({ name: 'id', description: 'Notification preference ID' })
async updatePreference(
@RestId() restaurantId: string,
@Param('id') preferenceId: string,
@Body() dto: UpdatePreferenceDto,
) {
return this.preferenceService.updatePreference(preferenceId, restaurantId, dto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_SETTINGS)
@Post('admin/notification-preferences')
@ApiOperation({ summary: 'Create notification preference' })
async createPreference(
@RestId() restaurantId: string,
@Body() dto: CreatePreferenceDto,
) {
return this.preferenceService.create(restaurantId, dto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Get('admin/notifications/sms-usage')
@ApiOperation({ summary: 'Get SMS usage for my restaurant' })
async getRestaurantSmsUsage(@RestId() restaurantId: string) {
const smsCount = await this.notificationService.getSmsCountByRestaurantId(restaurantId);
return { smsCount };
}
// super admin endpoints
@UseGuards(SuperAdminAuthGuard)
@ApiBearerAuth()
@Get('super-admin/notifications/sms-count')
@ApiOperation({ summary: 'Get SMS count for each restaurant with pagination' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)', minimum: 1 })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 10)', minimum: 1 })
async getSmsCount(
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
const parsedPage = page ? parseInt(page.toString(), 10) : 1;
const parsedLimit = limit ? parseInt(limit.toString(), 10) : 10;
return await this.notificationService.getSmsCountByRestaurant(parsedPage, parsedLimit);
}
}
@@ -0,0 +1,46 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { EntityManager } from '@mikro-orm/postgresql';
import { Notification } from '../entities/notification.entity';
@Injectable()
export class NotificationCrone {
private readonly logger = new Logger(NotificationCrone.name);
constructor(private readonly em: EntityManager) {}
// run every day at 03:00 (3:00 AM)
@Cron('0 3 * * *', {
name: 'deleteOldNotifications',
timeZone: 'UTC',
})
async handleCron() {
try {
this.logger.debug('Starting daily notification cleanup (removing notifications older than 24 hours)');
// Calculate the date 24 hours ago
const twentyFourHoursAgo = new Date();
twentyFourHoursAgo.setHours(twentyFourHoursAgo.getHours() - 24);
// Find all notifications created more than 24 hours ago
const oldNotifications = await this.em.find(Notification, {
createdAt: { $lt: twentyFourHoursAgo },
});
if (!oldNotifications || oldNotifications.length === 0) {
this.logger.debug('No old notifications found to delete');
return;
}
this.logger.log(`Found ${oldNotifications.length} notifications older than 24 hours to delete`);
// Delete the old notifications
await this.em.removeAndFlush(oldNotifications);
this.logger.log(`Successfully deleted ${oldNotifications.length} old notifications`);
} catch (err) {
this.logger.error(`NotificationCrone failed: ${err?.message}`, err);
}
}
}
@@ -0,0 +1,26 @@
import type { ExecutionContext } from '@nestjs/common';
import { createParamDecorator } from '@nestjs/common';
import type { AuthenticatedSocket } from '../guards/ws-admin-auth.guard';
/**
* Extract restaurant ID from authenticated WebSocket client
* Use this decorator in WebSocket handlers to get the restId from the authenticated admin
*
* @example
* ```typescript
* @SubscribeMessage('get:notifications')
* handleGetNotifications(@WsRestId() restId: string) {
* // restId is automatically extracted from authenticated admin
* }
* ```
*/
export const WsRestId = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
const client = ctx.switchToWs().getClient<AuthenticatedSocket>();
const restId = client.restId;
if (!restId) {
throw new Error('Restaurant ID not found. Ensure WsAdminAuthGuard is applied.');
}
return restId;
});
@@ -0,0 +1,40 @@
import { IsString, IsNotEmpty, IsOptional, IsArray, IsObject } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateNotificationDto {
@ApiProperty({ description: 'Notification title' })
@IsString()
@IsNotEmpty()
title: string;
@ApiProperty({ description: 'Notification message' })
@IsString()
@IsNotEmpty()
message: string;
@ApiProperty({ description: 'Notification type' })
@IsString()
@IsNotEmpty()
type: string;
@ApiPropertyOptional({ description: 'User ID' })
@IsString()
@IsOptional()
userId?: string;
@ApiPropertyOptional({ description: 'Phone number for SMS' })
@IsString()
@IsOptional()
phoneNumber?: string;
@ApiPropertyOptional({ description: 'Push notification tokens', type: [String] })
@IsArray()
@IsString({ each: true })
@IsOptional()
pushTokens?: string[];
@ApiPropertyOptional({ description: 'Additional data' })
@IsObject()
@IsOptional()
data?: Record<string, any>;
}
@@ -0,0 +1,24 @@
import { IsNotEmpty, IsEnum, IsArray } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { NotifTitleEnum } from '../interfaces/notification.interface';
import { NotifChannelEnum } from '../interfaces/notification.interface';
export class CreatePreferenceDto {
@ApiProperty({
description: 'Notification title/type (e.g., order.created, review.created)',
enum: NotifTitleEnum,
example: NotifTitleEnum.ORDER_CREATED,
})
@IsEnum(NotifTitleEnum)
@IsNotEmpty()
title!: NotifTitleEnum;
@ApiProperty({
description: 'Notification type (SMS, PUSH, BOTH, or NONE)',
enum: NotifChannelEnum,
example: NotifChannelEnum.SMS,
})
@IsArray()
@IsEnum(NotifChannelEnum, { each: true })
channels!: NotifChannelEnum[];
}
@@ -0,0 +1,38 @@
import { IsString, IsNotEmpty, IsOptional, IsObject } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class SendNotificationDto {
@ApiProperty({ description: 'Restaurant ID (ULID)' })
@IsString()
@IsNotEmpty()
restaurantId: string;
@ApiPropertyOptional({ description: 'User ID (ULID)' })
@IsString()
@IsOptional()
userId?: string;
@ApiProperty({ description: 'Notification type (e.g., ORDER_CONFIRMED, PAYMENT_FAILED)' })
@IsString()
@IsNotEmpty()
notificationType: string;
@ApiProperty({ description: 'Notification payload' })
@IsObject()
@IsNotEmpty()
payload: {
title?: string;
message: string;
body?: string;
phoneNumber?: string;
pushToken?: string;
data?: Record<string, any>;
templateId?: string;
parameters?: Array<{ name: string; value: string }>;
};
@ApiPropertyOptional({ description: 'Idempotency key to prevent duplicates' })
@IsString()
@IsOptional()
idempotencyKey?: string;
}
@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsEnum } from 'class-validator';
import { NotifChannelEnum } from '../interfaces/notification.interface';
export class UpdatePreferenceDto {
@ApiProperty({
description: 'Notification channels (can be empty or contain any enum values)',
enum: NotifChannelEnum,
isArray: true,
example: ['sms', 'push'],
})
@IsArray()
@IsEnum(NotifChannelEnum, { each: true })
channels!: NotifChannelEnum[];
}
@@ -0,0 +1,18 @@
import { Entity, Property, ManyToOne, Unique } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { NotifTitleEnum } from '../interfaces/notification.interface';
import { NotifChannelEnum } from '../interfaces/notification.interface';
@Entity({ tableName: 'notification_preferences' })
@Unique({ properties: ['restaurant', 'title'] })
export class NotificationPreference extends BaseEntity {
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@Property()
title!: NotifTitleEnum;
@Property({ type: 'json' })
channels: NotifChannelEnum[] = [];
}
@@ -0,0 +1,27 @@
import { Entity, Property, ManyToOne, Enum } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { User } from '../../user/entities/user.entity';
import { NotifTitleEnum } from '../interfaces/notification.interface';
import { Admin } from 'src/modules/admin/entities/admin.entity';
@Entity({ tableName: 'notifications' })
export class Notification extends BaseEntity {
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@ManyToOne(() => User, { nullable: true })
user?: User;
@ManyToOne(() => Admin, { nullable: true })
admin?: Admin;
@Enum(() => NotifTitleEnum)
title!: NotifTitleEnum;
@Property()
content!: string;
@Property({ nullable: true })
seenAt?: Date;
}
@@ -0,0 +1,17 @@
import { Entity, Property, ManyToOne, PrimaryKey } from '@mikro-orm/core';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
@Entity({ tableName: 'sms_logs' })
export class SmsLog {
@PrimaryKey()
id!: number;
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@Property()
phone!: string;
@Property()
createdAt: Date = new Date();
}
@@ -0,0 +1,7 @@
export class SmsSentEvent {
constructor(
public readonly phoneNumber: string,
public readonly restaurantId: string,
) {}
}
@@ -0,0 +1,125 @@
import { CanActivate, ExecutionContext, Injectable, Logger, Inject } from '@nestjs/common';
import { WsException } from '@nestjs/websockets';
import { Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { IAdminTokenPayload } from '../../auth/interfaces/IToken-payload';
export interface AuthenticatedSocket extends Socket {
adminId?: string;
restId?: string;
}
@Injectable()
export class WsAdminAuthGuard implements CanActivate {
private readonly logger = new Logger(WsAdminAuthGuard.name);
constructor(
@Inject(JwtService)
private readonly jwtService: JwtService,
@Inject(ConfigService)
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const client: Socket = context.switchToWs().getClient<Socket>();
try {
const token = this.extractToken(client);
if (!token) {
this.logger.warn('No token provided in WebSocket connection', {
socketId: client.id,
auth: client.handshake.auth,
query: client.handshake.query,
});
throw new WsException('Authentication required');
}
const secret = this.configService.getOrThrow<string>('JWT_SECRET');
const payload = await this.jwtService.verifyAsync<IAdminTokenPayload>(token, {
secret,
});
if (!payload.adminId || !payload.restId) {
this.logger.error('Invalid token payload structure', payload);
throw new WsException('Invalid token payload');
}
// Attach admin info to socket
(client as AuthenticatedSocket).adminId = payload.adminId;
(client as AuthenticatedSocket).restId = payload.restId;
this.logger.log(`Admin authenticated via WebSocket: ${payload.adminId}, restId: ${payload.restId}`);
return true;
} catch (error) {
if (error instanceof WsException) {
throw error;
}
this.logger.error('WebSocket authentication error', {
error: error instanceof Error ? error.message : 'Unknown error',
socketId: client.id,
});
throw new WsException('Authentication failed');
}
}
private extractToken(client: Socket): string | undefined {
// Try to get token from handshake auth (recommended for socket.io)
const auth = client.handshake.auth;
const authToken = (auth?.token as string | undefined) || (auth?.authorization as string | undefined);
if (authToken) {
// If it's "Bearer <token>", extract just the token
if (typeof authToken === 'string' && authToken.startsWith('Bearer ')) {
return authToken.substring(7);
}
return authToken;
}
// Try to get from query parameters (fallback)
const queryToken = client.handshake.query?.token || client.handshake.query?.authorization;
if (queryToken) {
if (typeof queryToken === 'string' && queryToken.startsWith('Bearer ')) {
return queryToken.substring(7);
}
return queryToken as string;
}
// Try to get from headers (if available)
const headers = client.handshake.headers;
const authHeader = headers.authorization || headers['authorization'];
if (authHeader && typeof authHeader === 'string') {
const [type, token] = authHeader.split(' ');
if (type?.toLowerCase() === 'bearer' && token) {
return token;
}
}
return undefined;
}
// private extractToken(client: Socket): string | undefined {
// const tryGet = (...values: any[]) => values.find(v => typeof v === 'string' && v.length > 0);
// let token = tryGet(
// client.handshake.auth?.token,
// client.handshake.auth?.authorization,
// client.handshake.query?.token,
// client.handshake.query?.authorization,
// client.handshake.headers.authorization,
// client.handshake.headers['authorization'],
// );
// if (!token) return undefined;
// if (token.startsWith('Bearer ')) {
// token = token.slice(7);
// }
// return token;
// }
}
@@ -0,0 +1,32 @@
import type { NotifTitleEnum, recipientType } from './notification.interface';
export interface SmsNotificationQueueJob {
recipient: recipientType;
templateId: string;
restaurantId: string;
parameters?: Record<string, string>;
}
export interface PushNotificationQueueJob {
title: string;
content: string;
icon: string;
action: {
type: string; //view order
url: string;
};
pushToken?: string; // FCM token for push notifications
pushTokens?: string[]; // Multiple FCM tokens for bulk push notifications
}
export interface InAppNotificationQueueJob {
recipient: { adminId: string; restaurantId: string };
subject: NotifTitleEnum;
body: string;
notificationId: string;
}
export interface SmsNotificationQueueJobResult {
success: boolean;
notificationId: string;
providerResponse?: Record<string, any>;
error?: string;
}
@@ -0,0 +1,76 @@
export enum NotifChannelEnum {
IN_APP = 'in-app',
SMS = 'sms',
PUSH = 'push',
}
export enum NotifTypeEnum {
TRANSACTIONAL = 'transactional',
PROMOTIONAL = 'promotional',
SYSTEM = 'system',
}
export enum NotifTitleEnum {
PAGER_CREATED = 'pager.created',
ORDER_CREATED = 'order.created',
PAYMENT_SUCCESS = 'payment.success',
REVIEW_CREATED = 'review.created',
ORDER_STATUS_CHANGED = 'order.status.changed',
}
export type recipientType = { userId: string } | { adminId: string };
export interface NotifRequestMessage {
title: NotifTitleEnum;
content: string;
sms: {
templateId: string;
parameters?: Record<string, string>;
};
pushNotif: {
title: string;
content: string;
icon: string;
action: {
type: string; //view order
url: string;
};
};
}
export interface NotifRequest {
// requestId: string;
// timestamp: Date;
// notifType: NotifTypeEnum;
// channels: NotifChannelEnum[];
restaurantId: string;
recipients: recipientType[];
message: NotifRequestMessage;
metadata: {
priority: number;
// retries: number;
};
}
//************************************************ */
interface INotifySmsPayload {
[key: string]: string | number | Date;
}
interface INotifySms {
phone: string;
subject: NotifTitleEnum;
}
export interface IInAppNotificationPayload {
notificationId: string;
subject: NotifTitleEnum;
body: string;
}
// 2. Payment Success
interface IPaymentSuccessSmsNotifyPayload extends INotifySmsPayload {
amount: number;
date: Date;
}
interface IPaymentSuccessSmsNotify extends INotifySms {
subject: NotifTitleEnum.PAYMENT_SUCCESS;
payload: IPaymentSuccessSmsNotifyPayload;
}
export type ISmsNotifyPayload = IPaymentSuccessSmsNotify;
@@ -0,0 +1,16 @@
export interface ISmsResponse {
status: number;
message: string;
}
export interface ISmsParams {
phone: string;
parameters?: Record<string, string | number | Date>;
templateId: string;
}
export interface ISmsBodyParameters {
Mobile: string;
TemplateId: string;
Parameters: { name: string; value: string }[];
}
@@ -0,0 +1,55 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { SmsSentEvent } from '../events/sms.events';
import { RestRepository } from '../../restaurants/repositories/rest.repository';
import { EntityManager } from '@mikro-orm/postgresql';
import { SmsLog } from '../entities/smsLogs.entity';
@Injectable()
export class SmsListeners {
private readonly logger = new Logger(SmsListeners.name);
constructor(
private readonly restRepository: RestRepository,
private readonly em: EntityManager,
) {
}
@OnEvent(SmsSentEvent.name)
async handleSmsSent(event: SmsSentEvent) {
try {
this.logger.log(
`SMS sent event received: phone ${event.phoneNumber} for restaurant: ${event.restaurantId}`,
);
// Get the restaurant entity
const restaurant = await this.restRepository.findOne({ id: event.restaurantId });
if (!restaurant) {
this.logger.warn(
`Restaurant not found for SMS log: ${event.restaurantId}`,
);
return;
}
// Create SMS log record
const smsLog = this.em.create(SmsLog, {
restaurant: restaurant,
phone: event.phoneNumber,
createdAt: new Date(),
});
await this.em.flush();
this.logger.log(
`SMS log created successfully: ${smsLog.id} for phone ${event.phoneNumber}`,
);
} catch (error) {
this.logger.error(
`Failed to create SMS log for event: ${event.restaurantId}`,
error instanceof Error ? error.stack : String(error),
);
}
}
}
@@ -0,0 +1,115 @@
import {
WebSocketGateway,
WebSocketServer,
// SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
// MessageBody,
// ConnectedSocket,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger, Inject, forwardRef } from '@nestjs/common';
import { NotificationService } from './services/notification.service';
import { WsAdminAuthGuard, type AuthenticatedSocket } from './guards/ws-admin-auth.guard';
import { ModuleRef } from '@nestjs/core';
import { ExecutionContext } from '@nestjs/common';
import { IInAppNotificationPayload } from './interfaces/notification.interface';
@WebSocketGateway({
cors: {
origin: true,
credentials: true,
},
namespace: '/notifications',
transports: ['websocket', 'polling'],
})
export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(NotificationsGateway.name);
private readonly pingIntervals = new Map<string, NodeJS.Timeout>();
constructor(
@Inject(forwardRef(() => NotificationService))
private readonly notificationService: NotificationService,
private readonly moduleRef: ModuleRef,
) {}
async handleConnection(client: Socket) {
try {
await this.authenticateConnection(client);
const authenticatedClient = client as AuthenticatedSocket;
this.logger.log(`Admin connected: ${authenticatedClient.adminId}`);
this.handleJoinRoom(authenticatedClient);
// Start ping interval for this client
// const intervalId = setInterval(() => {
// void authenticatedClient.emit('ping', { message: 'ping', timestamp: new Date().toISOString() });
// }, 2000);
// this.pingIntervals.set(client.id, intervalId);
} catch (error) {
this.logger.error(
`Connection authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
void client.emit('error', { message: 'Authentication failed' });
client.disconnect();
}
}
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
this.handleLeaveRoom(client);
// Clear ping interval for this client
// const intervalId = this.pingIntervals.get(client.id);
// if (intervalId) {
// clearInterval(intervalId);
// this.pingIntervals.delete(client.id);
// }
}
sendInAppNotification(repipient: { adminId: string; restaurantId: string }, payload: IInAppNotificationPayload) {
const room = this.getRoom(repipient.adminId, repipient.restaurantId);
this.logger.log(`Sending in app notification to admin: ${repipient.adminId}`);
this.server.to(room).emit('notifications', payload, async () => {
// 3. ACK (delivered)
// await this.notificationService.markAsDelivered(payload.notificationId);
});
this.logger.log(`In app notification sent to admin: ${repipient.adminId}`);
}
private getRoom(adminId: string, restaurantId: string) {
return `restaurant:${restaurantId}-admin:${adminId}`;
}
handleLeaveRoom(client: AuthenticatedSocket) {
const room = this.getRoom(client.adminId!, client.restId!);
void client.leave(room);
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) left room: ${room}`);
void client.emit('left', { room, message: 'Successfully Admin left room' });
}
handleJoinRoom(client: AuthenticatedSocket) {
const room = this.getRoom(client.adminId!, client.restId!);
void client.join(room);
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) joined room: ${room}`);
void client.emit('joined', { room, message: 'Successfully Admin joined room' });
}
private async authenticateConnection(client: Socket): Promise<void> {
const guard = this.moduleRef.get(WsAdminAuthGuard);
const context = {
switchToWs: () => ({ getClient: () => client }),
getClass: () => NotificationsGateway,
getHandler: () => this.handleConnection,
} as unknown as ExecutionContext;
const canActivate = await guard.canActivate(context);
if (!canActivate) {
throw new Error('Authentication failed');
}
}
}
@@ -0,0 +1,76 @@
import { Module, forwardRef } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { BullModule } from '@nestjs/bullmq';
import { JwtModule } from '@nestjs/jwt';
import { Notification } from './entities/notification.entity';
import { NotificationPreference } from './entities/notification-preference.entity';
import { NotificationService } from './services/notification.service';
import { NotificationPreferenceService } from './services/notification-preference.service';
import { NotificationQueueService } from './services/notification-queue.service';
import { PushNotificationService } from './services/push-notification.service';
import { SmsProcessor } from './processors/sms.processor';
import { PushProcessor } from './processors/push.processor';
import { NotificationsController } from './controllers/notifications.controller';
import { User } from '../user/entities/user.entity';
import { Restaurant } from '../restaurants/entities/restaurant.entity';
import { AuthModule } from '../auth/auth.module';
import { ConfigService } from '@nestjs/config';
import { NotificationQueueNameEnum } from './constants/queue';
import { UtilsModule } from '../util/utils.module';
import { NotificationsGateway } from './notifications.gateway';
import { WsAdminAuthGuard } from './guards/ws-admin-auth.guard';
import { InAppProcessor } from './processors/in-app.processor';
import { AdminModule } from '../admin/admin.module';
import { UserModule } from '../user/user.module';
import { NotificationCrone } from './crone/notification.crone';
import { SmsService } from './services/sms.service';
import { SmsLog } from './entities/smsLogs.entity';
import { SmsLogRepository } from './repositories/sms-log.repository';
import { RestaurantsModule } from '../restaurants/restaurants.module';
import { SmsListeners } from './listeners/sms.listeners';
@Module({
imports: [
forwardRef(() => AuthModule),
JwtModule,
MikroOrmModule.forFeature([Notification, NotificationPreference, User, Restaurant, SmsLog]),
BullModule.registerQueue(
{ name: NotificationQueueNameEnum.SMS },
{ name: NotificationQueueNameEnum.PUSH },
{ name: NotificationQueueNameEnum.IN_APP },
),
BullModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
connection: {
host: config.get('REDIS_HOST') || 'captain.dev.danakcorp.com',
port: config.get('REDIS_PORT') || 50601,
password: config.get('REDIS_PASSWORD'), // Pull from .env
},
}),
}),
AdminModule,
forwardRef(() => UserModule),
UtilsModule,
forwardRef(() => RestaurantsModule),
],
controllers: [NotificationsController],
providers: [
NotificationService,
NotificationPreferenceService,
NotificationQueueService,
PushNotificationService,
PushProcessor,
SmsProcessor,
InAppProcessor,
NotificationsGateway,
WsAdminAuthGuard,
NotificationCrone,
SmsService,
SmsLogRepository,
SmsListeners,
],
exports: [NotificationService, NotificationPreferenceService,
NotificationQueueService, PushNotificationService, SmsService],
})
export class NotificationsModule { }
@@ -0,0 +1,50 @@
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityManager } from '@mikro-orm/postgresql';
import { Notification } from '../entities/notification.entity';
import { InAppNotificationQueueJob } from '../interfaces/jobs-queue.interface';
import { NotificationQueueNameEnum } from '../constants/queue';
import { NotificationsGateway } from '../notifications.gateway';
@Processor(NotificationQueueNameEnum.IN_APP)
export class InAppProcessor extends WorkerHost {
private readonly logger = new Logger(InAppProcessor.name);
constructor(
@InjectRepository(Notification)
private readonly em: EntityManager,
private readonly notificationsGateway: NotificationsGateway,
) {
super();
}
async process(job: Job<InAppNotificationQueueJob>): Promise<void> {
const { recipient, subject, body, notificationId } = job.data;
this.logger.log(`Processing InApp notification - Recipient: ${JSON.stringify(recipient)}, subject: ${subject}`);
try {
this.notificationsGateway.sendInAppNotification(
{ adminId: recipient.adminId, restaurantId: recipient.restaurantId },
{ subject, body, notificationId },
);
this.logger.log(`InApp notification sent successfully to ${recipient.adminId}`);
} catch (error) {
this.logger.error(`Error processing InApp 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,96 @@
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 { PushNotificationQueueJob } from '../interfaces/jobs-queue.interface';
import { NotificationQueueNameEnum } from '../constants/queue';
import { PushNotificationService } from '../services/push-notification.service';
@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,
private readonly pushNotificationService: PushNotificationService,
) {
super();
}
async process(job: Job<PushNotificationQueueJob>) {
const { title, content, action, pushToken, pushTokens } = job.data;
this.logger.log(`Processing push notification job: ${job.id} - Title: ${title},`);
try {
// Get push token from job data
const pushToken = job.data.pushToken;
const pushTokens = job.data.pushTokens;
if (!pushToken && (!pushTokens || pushTokens.length === 0)) {
this.logger.warn(`Push token(s) not provided for notification `);
throw new Error('Push token or pushTokens array is required for push notifications');
}
// Send push notification
// Convert NotificationTitle enum to a readable string for the notification title
const notificationTitle = String(title)
.replace(/\./g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
let pushResult;
if (pushTokens && pushTokens.length > 0) {
// Send to multiple tokens
pushResult = await this.pushNotificationService.sendToMultipleTokens({
title: notificationTitle,
body: content,
tokens: pushTokens,
data: {
action,
},
});
} else if (pushToken) {
// Send to single token
pushResult = await this.pushNotificationService.sendToToken({
title: notificationTitle,
body: content,
token: pushToken,
data: {
action,
},
});
} else {
throw new Error('Push token or pushTokens array is required');
}
if (!pushResult.success) {
throw new Error(pushResult.error || 'Failed to send push notification');
}
this.logger.log(`Push notification sent successfully`);
return {
success: true,
providerResponse: { messageId: pushResult.messageId },
};
} catch (error) {
this.logger.error(`Error processing push 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,81 @@
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { UserRepository } from 'src/modules/user/repositories/user.repository';
import { SmsNotificationQueueJob } from '../interfaces/jobs-queue.interface';
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
import { NotificationQueueNameEnum } from '../constants/queue';
import { SmsService } from '../services/sms.service';
import { SmsSentEvent } from '../events/sms.events';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Processor(NotificationQueueNameEnum.SMS)
export class SmsProcessor extends WorkerHost {
private readonly logger = new Logger(SmsProcessor.name);
constructor(
private readonly adminRepository: AdminRepository,
private readonly userRepository: UserRepository,
private readonly smsService: SmsService,
private readonly eventEmitter: EventEmitter2,
) {
super();
}
async process(job: Job<SmsNotificationQueueJob>) {
const { recipient, templateId, parameters, restaurantId } = job.data;
this.logger.log(`Processing SMS notification - Recipient: ${JSON.stringify(recipient)}, templateID: ${templateId}`);
try {
let phone!: string;
if ('userId' in recipient) {
const user = await this.userRepository.findOne({ id: recipient.userId });
if (!user || !user.phone) {
this.logger.warn(`User ${recipient.userId} not found or has no phone number`);
throw new Error('User phone number is required for SMS notifications');
}
phone = user.phone;
}
if ('adminId' in recipient) {
const admin = await this.adminRepository.findOne({ id: recipient.adminId });
if (!admin || !admin.phone) {
this.logger.warn(`Admin ${recipient.adminId} not found or has no phone number`);
throw new Error('Admin phone number is required for SMS notifications');
}
phone = admin.phone;
}
if (!phone) {
this.logger.warn(`Phone number not found for recipient ${JSON.stringify(recipient)}`);
throw new Error('Phone number is required for SMS notifications');
}
// Send SMS notification
await this.smsService.sendSms({
phone,
templateId,
parameters,
});
this.logger.log(`SMS notification sent successfully to ${phone}`);
this.eventEmitter.emit(SmsSentEvent.name, new SmsSentEvent(phone, restaurantId));
return {
success: true,
};
} catch (error) {
this.logger.error(`Error processing SMS 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,76 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { SmsLog } from '../entities/smsLogs.entity';
import { PaginatedResult } from '../../../common/interfaces/pagination.interface';
@Injectable()
export class SmsLogRepository extends EntityRepository<SmsLog> {
constructor(readonly em: EntityManager) {
super(em, SmsLog);
}
async getSmsCountByRestaurant(
page: number = 1,
limit: number = 10,
): Promise<PaginatedResult<{ restaurantId: string; restaurantName: string; smsCount: number }>> {
const offset = (page - 1) * limit;
// Get total count of unique restaurants with SMS logs
const totalResult = await this.em.execute(
`
SELECT COUNT(DISTINCT sl.restaurant_id) as total
FROM sms_logs sl
WHERE sl.restaurant_id IS NOT NULL
`,
);
const total = parseInt(totalResult[0]?.total || '0', 10);
// Get paginated results with SMS counts grouped by restaurant
const results = await this.em.execute(
`
SELECT
r.id as "restaurantId",
r.name as "restaurantName",
COUNT(sl.id)::int as "smsCount"
FROM sms_logs sl
INNER JOIN restaurants r ON sl.restaurant_id = r.id
GROUP BY r.id, r.name
ORDER BY "smsCount" DESC, r.name ASC
LIMIT ? OFFSET ?
`,
[limit, offset],
);
const data = results.map((row: any) => ({
restaurantId: row.restaurantId,
restaurantName: row.restaurantName || 'Unknown',
smsCount: parseInt(row.smsCount || '0', 10),
}));
const totalPages = Math.ceil(total / limit);
return {
data,
meta: {
total,
page,
limit,
totalPages,
},
};
}
async getSmsCountByRestaurantId(restaurantId: string): Promise<number> {
const result = await this.em.execute(
`
SELECT COUNT(sl.id)::int as "smsCount"
FROM sms_logs sl
WHERE sl.restaurant_id = ?
`,
[restaurantId],
);
return parseInt(result[0]?.smsCount || '0', 10);
}
}
@@ -0,0 +1,85 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql';
import { NotificationPreference } from '../entities/notification-preference.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { CreatePreferenceDto } from '../dto/create-preference.dto';
import { UpdatePreferenceDto } from '../dto/update-preference.dto';
import { NotifTitleEnum } from '../interfaces/notification.interface';
@Injectable()
export class NotificationPreferenceService {
constructor(private readonly em: EntityManager) { }
async create(restaurantId: string, dto: CreatePreferenceDto): Promise<NotificationPreference> {
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
if (!restaurant) {
throw new NotFoundException('Restaurant not found');
}
const preference = this.em.create(NotificationPreference, {
restaurant,
channels: dto.channels,
title: dto.title,
});
await this.em.persistAndFlush(preference);
return preference;
}
async findByRestaurant(restaurantId: string): Promise<NotificationPreference[]> {
return this.em.find(NotificationPreference, {
restaurant: { id: restaurantId },
});
}
async findByRestaurantAndType(restaurantId: string, title: NotifTitleEnum): Promise<NotificationPreference | null> {
return this.em.findOne(NotificationPreference, {
restaurant: { id: restaurantId },
title,
});
}
// async updateEnabled(
// restaurantId: string,
// notificationType: string,
// enabled: boolean,
// ): Promise<NotificationPreference> {
// const preference = await this.findByRestaurantAndType(restaurantId, notificationType);
// if (!preference) {
// throw new NotFoundException('Notification preference not found');
// }
// preference.enabled = enabled;
// await this.em.persistAndFlush(preference);
// return preference;
// }
async updatePreference(
preferenceId: string,
restaurantId: string,
dto: UpdatePreferenceDto,
): Promise<NotificationPreference> {
const preference = await this.em.findOne(NotificationPreference, {
id: preferenceId,
restaurant: { id: restaurantId },
});
if (!preference) {
throw new NotFoundException('Notification preference not found');
}
this.em.assign(preference, dto);
await this.em.persistAndFlush(preference);
return preference;
}
async delete(restaurantId: string, preferenceId: string): Promise<void> {
const preference = await this.em.findOne(NotificationPreference, {
restaurant: { id: restaurantId },
id: preferenceId,
});
if (!preference) {
throw new NotFoundException('Notification preference not found');
}
await this.em.removeAndFlush(preference);
}
}
@@ -0,0 +1,145 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { NotificationQueueNameEnum } from '../constants/queue';
import {
InAppNotificationQueueJob,
PushNotificationQueueJob,
SmsNotificationQueueJob,
} from '../interfaces/jobs-queue.interface';
@Injectable()
export class NotificationQueueService {
private readonly logger = new Logger(NotificationQueueService.name);
constructor(
@InjectQueue(NotificationQueueNameEnum.SMS) private readonly smsQueue: Queue,
@InjectQueue(NotificationQueueNameEnum.PUSH) private readonly pushQueue: Queue,
@InjectQueue(NotificationQueueNameEnum.IN_APP) private readonly inAppQueue: Queue,
) {}
async addSmsNotification(job: SmsNotificationQueueJob): Promise<void> {
try {
const jobId = `${job.templateId}-${this.generateRandomInt()}-${Date.now()}`;
await this.smsQueue.add('sms-notification', job, {
jobId,
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: {
age: 24 * 3600, // Keep completed jobs for 24 hours
count: 1000,
},
removeOnFail: {
age: 7 * 24 * 3600, // Keep failed jobs for 7 days
},
});
this.logger.log(`SMS notification job added to queue: ${jobId}`);
} catch (error) {
this.logger.error(`Failed to add SMS notification to queue:`, error);
throw error;
}
}
async addPushNotification(job: PushNotificationQueueJob): Promise<void> {
try {
const jobId = `${job.title}-${this.generateRandomInt()}-${Date.now()}`;
await this.pushQueue.add('push-notification', job, {
jobId,
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: {
age: 24 * 3600, // Keep completed jobs for 24 hours
count: 1000,
},
removeOnFail: {
age: 7 * 24 * 3600, // Keep failed jobs for 7 days
},
});
this.logger.log(`Push notification job added to queue: ${jobId}`);
} catch (error) {
this.logger.error(`Failed to add push notification to queue:`, error);
throw error;
}
}
async addBulkSmsNotifications(jobs: SmsNotificationQueueJob[]): Promise<void> {
try {
const queueJobs = jobs.map(job => ({
name: 'sms-notification',
data: job,
opts: {
jobId: `${job.templateId}-${this.generateRandomInt()}`,
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
},
}));
await this.smsQueue.addBulk(queueJobs);
this.logger.log(`Added ${jobs.length} SMS notification jobs to queue`);
} catch (error) {
this.logger.error(`Failed to add bulk notifications to queue:`, error);
throw error;
}
}
async addBulkPushNotifications(jobs: PushNotificationQueueJob[]): Promise<void> {
try {
const queueJobs = jobs.map(job => ({
name: 'push-notification',
data: job,
opts: {
jobId: `${job.title}-${this.generateRandomInt()}-${Date.now()}`,
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
},
}));
await this.pushQueue.addBulk(queueJobs);
this.logger.log(`Added ${jobs.length} Push notification jobs to queue`);
} catch (error) {
this.logger.error(`Failed to add bulk notifications to queue:`, error);
throw error;
}
}
async addBulkInAppNotifications(jobs: InAppNotificationQueueJob[]): Promise<void> {
try {
const queueJobs = jobs.map(job => ({
name: 'in-app-notification',
data: job,
opts: {
jobId: `${job.subject}-${job.notificationId}-${Date.now()}`,
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
},
}));
await this.inAppQueue.addBulk(queueJobs);
this.logger.log(`Added ${jobs.length} InApp notification jobs to queue`);
} catch (error) {
this.logger.error(`Failed to add bulk notifications to queue:`, error);
throw error;
}
}
private generateRandomInt(): string {
return Math.random().toString(36).substring(2, 15);
}
}
@@ -0,0 +1,282 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { EntityManager, FilterQuery } from '@mikro-orm/postgresql';
import { Notification } from '../entities/notification.entity';
import { NotificationPreferenceService } from './notification-preference.service';
import { NotificationQueueService } from './notification-queue.service';
import { NotificationsGateway } from '../notifications.gateway';
import { NotifChannelEnum, NotifRequest, NotifTitleEnum } from '../interfaces/notification.interface';
import { SmsLog } from '../entities/smsLogs.entity';
import { SmsLogRepository } from '../repositories/sms-log.repository';
import { PaginatedResult } from '../../../common/interfaces/pagination.interface';
@Injectable()
export class NotificationService {
private readonly logger = new Logger(NotificationService.name);
constructor(
private readonly em: EntityManager,
private readonly preferenceService: NotificationPreferenceService,
private readonly queueService: NotificationQueueService,
private readonly notificationGateway: NotificationsGateway,
private readonly smsLogRepository: SmsLogRepository,
) { }
async sendNotification(params: NotifRequest): Promise<Notification[]> {
const { recipients, message, metadata, restaurantId } = params;
// create Database notifications
const notifications = await this.createAdminBulkNotifications(
recipients.map(recipient => ({
restaurantId,
title: message.title,
content: message.content,
adminId: 'adminId' in recipient ? recipient.adminId : null,
userId: 'userId' in recipient ? recipient.userId : null,
})),
);
// get admin prefrences
const preference = await this.preferenceService.findByRestaurantAndType(restaurantId, message.title);
if (preference?.channels?.length === 0) {
this.logger.warn(`Notification type is NONE for restaurant ${restaurantId}, title ${message.title}`);
return notifications;
}
// send in app notification
if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) {
await this.queueService.addBulkInAppNotifications(
notifications.map(notification => ({
recipient: { adminId: notification.admin?.id || '', restaurantId },
subject: message.title,
body: message.content,
notificationId: notification.id,
})),
);
}
// add sms notifications to queue
if (preference?.channels?.includes(NotifChannelEnum.SMS)) {
await this.queueService.addBulkSmsNotifications(
recipients.map(recipient => ({
recipient,
templateId: message.sms.templateId,
parameters: message.sms.parameters,
restaurantId,
})),
);
}
this.logger.log(`Queued notification for restaurant ${restaurantId}, title ${message.title}`);
return notifications;
}
async createAdminBulkNotifications(
params: {
restaurantId: string;
title: NotifTitleEnum;
content: string;
adminId: string | null;
userId: string | null;
}[],
): Promise<Notification[]> {
const notifications = params.map(param => {
return this.em.create(Notification, {
restaurant: param.restaurantId,
admin: param.adminId,
user: param.userId && param.userId.trim() !== '' ? param.userId : null,
title: param.title,
content: param.content,
});
});
await this.em.persistAndFlush(notifications);
return notifications;
}
async findOne(id: string): Promise<Notification> {
const notification = await this.em.findOne(Notification, { id }, { populate: ['restaurant', 'user'] });
if (!notification) {
throw new NotFoundException('Notification not found');
}
return notification;
}
async findByRestaurant(
restaurantId: string,
adminId: string,
limit = 50,
cursor?: string,
status?: 'seen' | 'unseen',
): Promise<{ data: Notification[]; nextCursor: string | null }> {
const where: FilterQuery<Notification> = {
restaurant: { id: restaurantId },
admin: { id: adminId },
};
// Filter by status (seen/unseen)
if (status === 'seen') {
where.seenAt = { $ne: null };
} else if (status === 'unseen') {
where.seenAt = null;
}
// Cursor-based pagination: if cursor is provided, get items with id < cursor
if (cursor) {
where.id = { $lt: cursor };
}
const notifications = await this.em.find(Notification, where, {
orderBy: { createdAt: 'DESC', id: 'DESC' },
limit: limit + 1, // fetch one extra to determine next page
populate: ['user'],
});
const hasNextPage = notifications.length > limit;
const data = hasNextPage ? notifications.slice(0, limit) : notifications;
const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
return {
data,
nextCursor: nextCursor ?? null,
};
}
async findByUserAndRestaurant(
userId: string,
restaurantId: string,
limit = 50,
cursor?: string,
status?: 'seen' | 'unseen',
): Promise<{ data: Notification[]; nextCursor: string | null }> {
const where: FilterQuery<Notification> = {
user: { id: userId },
restaurant: { id: restaurantId },
};
// Filter by status (seen/unseen)
if (status === 'seen') {
where.seenAt = { $ne: null };
} else if (status === 'unseen') {
where.seenAt = null;
}
// Cursor-based pagination: if cursor is provided, get items with id < cursor (since ULIDs are time-ordered)
if (cursor) {
where.id = { $lt: cursor };
}
const notifications = await this.em.find(Notification, where, {
orderBy: { createdAt: 'DESC', id: 'DESC' },
limit: limit + 1, // Fetch one extra to determine if there's a next page
});
// Check if there's a next page
const hasNextPage = notifications.length > limit;
const data = hasNextPage ? notifications.slice(0, limit) : notifications;
const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
return {
data,
nextCursor: nextCursor ?? null,
};
}
async readNotificationAdmin(id: string, adminId: string, restaurantId: string): Promise<void> {
const notification = await this.em.findOne(Notification, {
id,
admin: { id: adminId },
restaurant: { id: restaurantId },
});
if (!notification) {
throw new NotFoundException('Notification not found');
}
notification.seenAt = new Date();
await this.em.persistAndFlush(notification);
}
async readNotificationAsUser(id: string, userId: string, restaurantId: string): Promise<void> {
const notification = await this.em.findOne(Notification, {
id,
user: { id: userId },
restaurant: { id: restaurantId },
});
if (!notification) {
throw new NotFoundException('Notification not found');
}
notification.seenAt = new Date();
await this.em.persistAndFlush(notification);
}
async findByRestaurantAndType(restaurantId: string, title: NotifTitleEnum, limit = 50): Promise<Notification[]> {
return this.em.find(
Notification,
{
restaurant: { id: restaurantId },
title,
},
{
orderBy: { createdAt: 'DESC' },
limit,
populate: ['user'],
},
);
}
async findByAdminAndRestaurant(adminId: string, restaurantId: string, limit = 50): Promise<Notification[]> {
return this.em.find(
Notification,
{ admin: { id: adminId }, restaurant: { id: restaurantId } },
{
orderBy: { createdAt: 'DESC' },
limit,
},
);
}
async countUnseenByUserAndRestaurant(userId: string, restaurantId: string): Promise<number> {
const where: FilterQuery<Notification> = {
user: { id: userId },
restaurant: { id: restaurantId },
seenAt: null,
};
return this.em.count(Notification, where);
}
async countUnseenByRestaurant(adminId: string, restaurantId: string): Promise<number> {
const where: FilterQuery<Notification> = {
admin: { id: adminId },
restaurant: { id: restaurantId },
seenAt: null,
};
return this.em.count(Notification, where);
}
readAllNotifsAsUser(userId: string, restaurantId: string): Promise<number> {
const where: FilterQuery<Notification> = {
user: { id: userId },
restaurant: { id: restaurantId },
seenAt: null,
};
return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
}
readAllNotifsAsAdmin(adminId: string, restaurantId: string): Promise<number> {
const where: FilterQuery<Notification> = {
admin: { id: adminId },
restaurant: { id: restaurantId },
seenAt: null,
};
return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
}
async getSmsCountByRestaurant(
page: number = 1,
limit: number = 10,
): Promise<PaginatedResult<{ restaurantId: string; restaurantName: string; smsCount: number }>> {
return this.smsLogRepository.getSmsCountByRestaurant(page, limit);
}
async getSmsCountByRestaurantId(restaurantId: string): Promise<number> {
return this.smsLogRepository.getSmsCountByRestaurantId(restaurantId);
}
}
@@ -0,0 +1,148 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as admin from 'firebase-admin';
export interface PushNotificationPayload {
title: string;
body: string;
data?: Record<string, any>;
token?: string;
tokens?: string[];
}
export interface PushNotificationResponse {
success: boolean;
messageId?: string;
error?: string;
}
@Injectable()
export class PushNotificationService {
private readonly logger = new Logger(PushNotificationService.name);
private firebaseApp: admin.app.App | null = null;
constructor(private readonly configService: ConfigService) {
this.initializeFirebase();
}
private initializeFirebase() {
try {
const firebaseConfig = this.configService.get<string>('FIREBASE_SERVICE_ACCOUNT');
if (!firebaseConfig) {
this.logger.warn('Firebase service account not configured. Push notifications will be disabled.');
return;
}
// Parse the service account JSON
const serviceAccount = JSON.parse(firebaseConfig);
if (!this.firebaseApp) {
this.firebaseApp = admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
this.logger.log('Firebase initialized successfully');
}
} catch (error) {
this.logger.error('Failed to initialize Firebase:', error);
this.logger.warn('Push notifications will be disabled');
}
}
async sendToToken(payload: PushNotificationPayload): Promise<PushNotificationResponse> {
if (!this.firebaseApp || !payload.token) {
throw new HttpException(
'Push notification service not configured or token missing',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
try {
const message: admin.messaging.Message = {
notification: {
title: payload.title,
body: payload.body,
},
data: this.convertDataToString(payload.data || {}),
token: payload.token,
android: {
priority: 'high',
},
apns: {
headers: {
'apns-priority': '10',
},
},
};
const response = await admin.messaging().send(message);
this.logger.log(`Push notification sent successfully: ${response}`);
return {
success: true,
messageId: response,
};
} catch (error) {
this.logger.error(`Failed to send push notification: ${error}`);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
async sendToMultipleTokens(payload: PushNotificationPayload): Promise<PushNotificationResponse> {
if (!this.firebaseApp || !payload.tokens || payload.tokens.length === 0) {
throw new HttpException(
'Push notification service not configured or tokens missing',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
try {
const message: admin.messaging.MulticastMessage = {
notification: {
title: payload.title,
body: payload.body,
},
data: this.convertDataToString(payload.data || {}),
tokens: payload.tokens,
android: {
priority: 'high',
},
apns: {
headers: {
'apns-priority': '10',
},
},
};
const response = await admin.messaging().sendEachForMulticast(message);
this.logger.log(`Push notifications sent: ${response.successCount} successful, ${response.failureCount} failed`);
return {
success: response.successCount > 0,
messageId: response.responses[0]?.messageId,
error: response.failureCount > 0 ? `${response.failureCount} notifications failed` : undefined,
};
} catch (error) {
this.logger.error(`Failed to send push notifications: ${error}`);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
private convertDataToString(data: Record<string, any>): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(data)) {
result[key] = typeof value === 'string' ? value : JSON.stringify(value);
}
return result;
}
isConfigured(): boolean {
return this.firebaseApp !== null;
}
}
@@ -0,0 +1,80 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { ISmsParams, ISmsResponse, ISmsBodyParameters } from '../interfaces/sms';
@Injectable()
export class SmsService {
private readonly baseURL: string;
private readonly OTP: string;
private readonly apiKey: string;
constructor(
// private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {
this.baseURL = this.configService.getOrThrow<string>('SMS_BASE_URL');
this.apiKey = this.configService.getOrThrow<string>('SMS_API_KEY');
this.OTP = this.configService.getOrThrow<string>('SMS_PATTERN_OTP');
}
sendotp(phone: string, otp: string) {
return this.sendSms({
phone,
parameters: { otp },
templateId: this.OTP,
});
}
async sendSms(params: ISmsParams) {
const { parameters, phone, templateId } = params;
const parametersArray = Object.entries(parameters ?? {}).map(([name, value]) => ({
name,
value: value.toString(),
}));
const cleanedPhone = this.formatPhone(phone);
const smsData: ISmsBodyParameters = {
Parameters: parametersArray,
Mobile: cleanedPhone,
TemplateId: templateId,
};
const url = `${this.baseURL}/send/verify`;
try {
const { data } = await axios.post<ISmsResponse>(url, smsData, {
headers: {
'Content-Type': 'application/json',
'X-API-KEY': this.apiKey,
},
});
if (data.status !== 1) {
throw new HttpException('The SMS was not sent. Please try again later.', HttpStatus.BAD_GATEWAY);
}
return data;
} catch (error) {
console.error('SMS sending failed:', JSON.stringify(error));
throw new HttpException('The SMS was not sent. Please try again later.', HttpStatus.BAD_GATEWAY);
}
}
formatPhone(phone: string): string {
// remove spaces, dashes, parentheses
phone = phone.replace(/[^\d+]/g, '');
if (phone.startsWith('+98')) {
return phone;
}
if (phone.startsWith('98')) {
return `+${phone}`;
}
if (phone.startsWith('0')) {
return `+98${phone.slice(1)}`;
}
return `+98${phone}`;
}
}