auth
This commit is contained in:
@@ -8,8 +8,6 @@ import { UploaderModule } from './modules/uploader/uploader.module';
|
||||
import { AdminModule } from './modules/admin/admin.module';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { cacheConfig } from './config/cache.config';
|
||||
@@ -32,8 +30,6 @@ import { CatalogueModule } from './modules/catalogue/catalogue.module';
|
||||
},
|
||||
]),
|
||||
ScheduleModule.forRoot(),
|
||||
|
||||
NotificationsModule,
|
||||
EventEmitterModule.forRoot(),
|
||||
BusinessModule,
|
||||
CatalogueModule,
|
||||
|
||||
@@ -9,7 +9,6 @@ import { TokensService } from './services/tokens.service';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { AdminAuthGuard } from './guards/adminAuth.guard';
|
||||
import { RefreshToken } from './entities/refresh-token.entity';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { BusinessModule } from '../business/business.module';
|
||||
|
||||
@Module({
|
||||
@@ -32,7 +31,6 @@ import { BusinessModule } from '../business/business.module';
|
||||
forwardRef(() => AdminModule),
|
||||
forwardRef(() => BusinessModule),
|
||||
MikroOrmModule.forFeature([RefreshToken]),
|
||||
forwardRef(() => NotificationsModule),
|
||||
BusinessModule
|
||||
],
|
||||
controllers: [AuthController],
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { RequestOtpDto } from '../dto/request-otp.dto';
|
||||
import { DirectLoginDto } from '../dto/direct-login.dto';
|
||||
import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger';
|
||||
import { VerifyOtpDto } from '../dto/verify-otp.dto';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { RefreshTokenRateLimit } from 'src/common/decorators/rate-limit.decorator';
|
||||
import { RefreshTokenDto } from '../dto/refresh-token.dto';
|
||||
import { SuperAdminAuthGuard } from '../guards/superAdminAuth.guard';
|
||||
|
||||
@ApiTags('auth')
|
||||
|
||||
@@ -2,13 +2,9 @@ import { Entity, Enum, Index, Property } from '@mikro-orm/core';
|
||||
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
|
||||
export enum RefreshTokenType {
|
||||
USER = 'user',
|
||||
ADMIN = 'admin',
|
||||
}
|
||||
|
||||
@Entity({ tableName: 'refreshtokens' })
|
||||
@Index({ properties: ['ownerId', 'restId', 'type'] })
|
||||
@Index({ properties: ['adminId', 'businessId'] })
|
||||
@Index({ properties: ['hashedToken'] })
|
||||
@Index({ properties: ['expiresAt'] })
|
||||
export class RefreshToken extends BaseEntity {
|
||||
@@ -16,13 +12,10 @@ export class RefreshToken extends BaseEntity {
|
||||
hashedToken!: string;
|
||||
|
||||
@Property()
|
||||
ownerId!: string;
|
||||
adminId!: string;
|
||||
|
||||
@Property()
|
||||
restId!: string;
|
||||
|
||||
@Enum(() => RefreshTokenType)
|
||||
type!: RefreshTokenType;
|
||||
businessId!: string;
|
||||
|
||||
@Property({ type: 'timestamptz' })
|
||||
expiresAt!: Date;
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { RequestOtpDto } from '../dto/request-otp.dto';
|
||||
import { CacheService } from '../../utils/cache.service';
|
||||
import { SmsService } from '../../notifications/services/sms.service';
|
||||
import { randomInt } from 'crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TokensService } from './tokens.service';
|
||||
import { AuthMessage, RestMessage } from 'src/common/enums/message.enum';
|
||||
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { normalizePhone } from '../../utils/phone.util';
|
||||
import { BusinessService } from 'src/modules/business/business.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
readonly ADMIN_PERMISSIONS_KEY = 'admin-perms';
|
||||
private OTP_EXPIRATION_TIME: number;
|
||||
|
||||
constructor(
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly smsService: SmsService,
|
||||
private readonly tokensService: TokensService,
|
||||
private readonly adminRepository: AdminRepository,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly businessService: BusinessService,
|
||||
) {
|
||||
this.OTP_EXPIRATION_TIME = this.configService.get<number>('OTP_EXPIRATION_TIME') ?? 240;
|
||||
}
|
||||
|
||||
// private userOtpKey(restaurantSlug: string, phone: string) {
|
||||
@@ -33,8 +19,11 @@ export class AuthService {
|
||||
// return `otp-admin:${restaurantSlug}:${phone}`;
|
||||
// }
|
||||
|
||||
directLogin(danakSubId: string) {
|
||||
const business=this.businessService.findBySubIdOrFail(danakSubId)
|
||||
async directLogin(danakSubId: string) {
|
||||
const business = await this.businessService.findBySubIdOrFail(danakSubId)
|
||||
const admin = business.admin
|
||||
const tokens = await this.tokensService.generateAccessAndRefreshToken(admin.id, business.id);
|
||||
return tokens
|
||||
}
|
||||
|
||||
// async requestOtp(dto: RequestOtpDto, isAdmin: boolean) {
|
||||
|
||||
@@ -3,13 +3,12 @@ import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { LockMode } from '@mikro-orm/core';
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthMessage } from '../../../common/enums/message.enum';
|
||||
import { RefreshToken } from '../entities/refresh-token.entity';
|
||||
import { IAdminTokenPayload, ITokenPayload } from '../interfaces/IToken-payload';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { AuthMessage } from '../../../common/enums/message.enum';
|
||||
import { RefreshToken, RefreshTokenType } from '../entities/refresh-token.entity';
|
||||
import { IAdminTokenPayload, ITokenPayload } from '../interfaces/IToken-payload';
|
||||
|
||||
@Injectable()
|
||||
export class TokensService {
|
||||
private readonly logger = new Logger(TokensService.name);
|
||||
@@ -17,28 +16,23 @@ export class TokensService {
|
||||
private readonly configService: ConfigService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly em: EntityManager,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
async generateAccessAndRefreshToken(
|
||||
ownerId: string,
|
||||
restaurantId: string,
|
||||
isAdmin: boolean,
|
||||
slug: string,
|
||||
adminId: string,
|
||||
businessId: string,
|
||||
em?: EntityManager,
|
||||
) {
|
||||
const refreshExpire = this.configService.getOrThrow<number>('REFRESH_TOKEN_EXPIRE');
|
||||
const accessExpire = this.configService.getOrThrow<number>('JWT_EXPIRATION_TIME');
|
||||
|
||||
const payload: ITokenPayload | IAdminTokenPayload = isAdmin
|
||||
? { adminId: ownerId, restId: restaurantId }
|
||||
: { userId: ownerId, restId: restaurantId, slug };
|
||||
const payload: IAdminTokenPayload = { adminId: adminId, restId: businessId }
|
||||
|
||||
const accessToken = await this.generateAccessToken(payload, accessExpire);
|
||||
const refreshToken = this.generateRefreshToken();
|
||||
const type = isAdmin ? RefreshTokenType.ADMIN : RefreshTokenType.USER;
|
||||
|
||||
// Only pass em if it's a transaction manager (not this.em), otherwise pass undefined to trigger flush
|
||||
await this.storeRefreshToken(ownerId, restaurantId, refreshToken, type, em);
|
||||
await this.storeRefreshToken(adminId, businessId, refreshToken, em);
|
||||
|
||||
return {
|
||||
accessToken: { token: accessToken, expire: dayjs().add(accessExpire, 'seconds').valueOf() },
|
||||
@@ -57,7 +51,6 @@ export class TokensService {
|
||||
ownerId: string,
|
||||
restId: string,
|
||||
refreshToken: string,
|
||||
type: RefreshTokenType,
|
||||
em?: EntityManager,
|
||||
) {
|
||||
const entityManager = em || this.em;
|
||||
@@ -68,9 +61,8 @@ export class TokensService {
|
||||
|
||||
const token = entityManager.create(RefreshToken, {
|
||||
hashedToken,
|
||||
ownerId,
|
||||
restId,
|
||||
type,
|
||||
adminId: ownerId,
|
||||
businessId: restId,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
@@ -104,20 +96,13 @@ export class TokensService {
|
||||
}
|
||||
|
||||
// Store token data before removal
|
||||
const ownerId = token.ownerId;
|
||||
const restId = token.restId;
|
||||
const isAdmin = token.type === RefreshTokenType.ADMIN;
|
||||
|
||||
// Verify restaurant still exists
|
||||
// const restaurant = await em.findOne(Restaurant, { id: restId });
|
||||
// if (!restaurant) {
|
||||
// throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
|
||||
// }
|
||||
const adminId = token.adminId;
|
||||
const businessId = token.businessId;
|
||||
|
||||
try {
|
||||
// Generate new tokens first (before removing old token)
|
||||
// Pass the transaction EntityManager to ensure operations are within the transaction
|
||||
const tokens = await this.generateAccessAndRefreshToken(ownerId, "restaurant.id", isAdmin, "restaurant.slug", em);
|
||||
const tokens = await this.generateAccessAndRefreshToken(adminId, businessId, em);
|
||||
|
||||
// Remove old token only after new token is created
|
||||
em.remove(token);
|
||||
|
||||
@@ -43,7 +43,7 @@ export class BusinessService {
|
||||
}
|
||||
|
||||
async findBySubIdOrFail(danakSubscriptionId: string) {
|
||||
const business = await this.businessRepository.findOne({ danakSubscriptionId })
|
||||
const business = await this.businessRepository.findOne({ danakSubscriptionId }, { populate: ['admin'] })
|
||||
if (!business) {
|
||||
throw new NotFoundException("Business not found")
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export enum NotificationQueueNameEnum {
|
||||
SMS = 'sms',
|
||||
PUSH = 'push',
|
||||
IN_APP = 'in-app',
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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[];
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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[];
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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;
|
||||
@@ -1,16 +0,0 @@
|
||||
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 }[];
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { NotificationQueueService } from './services/notification-queue.service';
|
||||
import { SmsProcessor } from './processors/sms.processor';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NotificationQueueNameEnum } from './constants/queue';
|
||||
import { UtilsModule } from '../utils/utils.module';
|
||||
import { AdminModule } from '../admin/admin.module';
|
||||
import { SmsService } from './services/sms.service';
|
||||
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
forwardRef(() => AuthModule),
|
||||
JwtModule,
|
||||
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,
|
||||
UtilsModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
NotificationQueueService,
|
||||
SmsProcessor,
|
||||
SmsService,
|
||||
],
|
||||
exports: [
|
||||
NotificationQueueService, SmsService],
|
||||
})
|
||||
export class NotificationsModule { }
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
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 { 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 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 ('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}`);
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user