notification
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsArray, IsMobilePhone, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsMobilePhone, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
import { NotifEvent } from 'src/modules/notification/interfaces/notification.interface';
|
|
||||||
|
|
||||||
export class CreateAdminDto {
|
export class CreateAdminDto {
|
||||||
@ApiProperty({ description: 'Mobile phone number' })
|
@ApiProperty({ description: 'Mobile phone number' })
|
||||||
@@ -23,8 +22,4 @@ export class CreateAdminDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
roleId!: string;
|
roleId!: string;
|
||||||
|
|
||||||
@ApiProperty({})
|
|
||||||
@IsArray()
|
|
||||||
notificationPrefrences:NotifEvent[]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Cascade, Entity, ManyToOne, OneToOne, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
|
import { Cascade, Entity, ManyToOne, OptionalProps, Property } from '@mikro-orm/core';
|
||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
import { normalizePhone } from '../../util/phone.util';
|
import { normalizePhone } from '../../util/phone.util';
|
||||||
import { Role } from 'src/modules/roles/entities/role.entity';
|
import { Role } from 'src/modules/roles/entities/role.entity';
|
||||||
import { ulid } from 'ulid';
|
|
||||||
import { NotificationPreference } from 'src/modules/notification/entities/notification-preference.entity';
|
|
||||||
|
|
||||||
@Entity({ tableName: 'admins' })
|
@Entity({ tableName: 'admins' })
|
||||||
export class Admin extends BaseEntity {
|
export class Admin extends BaseEntity {
|
||||||
@@ -28,9 +26,4 @@ export class Admin extends BaseEntity {
|
|||||||
|
|
||||||
@ManyToOne(() => Role)
|
@ManyToOne(() => Role)
|
||||||
role: Role
|
role: Role
|
||||||
|
|
||||||
// @OneToOne(() => NotificationPreference, pref => pref.admin, {
|
|
||||||
|
|
||||||
// })
|
|
||||||
// notificationPreference!: NotificationPreference;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { normalizePhone } from '../../util/phone.util';
|
|||||||
import { CreateAdminDto } from '../dto/create-admin.dto';
|
import { CreateAdminDto } from '../dto/create-admin.dto';
|
||||||
import { AdminRepository } from '../repositories/admin.repository';
|
import { AdminRepository } from '../repositories/admin.repository';
|
||||||
import { RoleRepository } from 'src/modules/roles/respository/role.repository';
|
import { RoleRepository } from 'src/modules/roles/respository/role.repository';
|
||||||
import { NotificationPreference } from 'src/modules/notification/entities/notification-preference.entity';
|
|
||||||
import { PermissionEnum } from 'src/common/enums/permission.enum';
|
import { PermissionEnum } from 'src/common/enums/permission.enum';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -19,7 +18,7 @@ export class AdminService {
|
|||||||
) { }
|
) { }
|
||||||
|
|
||||||
async create(dto: CreateAdminDto) {
|
async create(dto: CreateAdminDto) {
|
||||||
const { phone, firstName, lastName, roleId, notificationPrefrences } = dto;
|
const { phone, firstName, lastName, roleId } = dto;
|
||||||
|
|
||||||
const normalizedPhone = normalizePhone(phone);
|
const normalizedPhone = normalizePhone(phone);
|
||||||
|
|
||||||
@@ -31,39 +30,25 @@ export class AdminService {
|
|||||||
throw new BadRequestException('This phone number is already used');
|
throw new BadRequestException('This phone number is already used');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// retrieve deleted admin
|
|
||||||
if (currentAdmin && currentAdmin.deletedAt) {
|
if (currentAdmin && currentAdmin.deletedAt) {
|
||||||
currentAdmin.deletedAt = undefined
|
currentAdmin.deletedAt = undefined;
|
||||||
await this.em.flush()
|
await this.em.flush();
|
||||||
return currentAdmin
|
return currentAdmin;
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = await this.roleRepository.findOne({ id: roleId })
|
const role = await this.roleRepository.findOne({ id: roleId });
|
||||||
if (!role) {
|
if (!role) {
|
||||||
throw new BadRequestException('Role not found');
|
throw new BadRequestException('Role not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const admin = this.em.create(Admin, {
|
||||||
return this.em.transactional(async em => {
|
|
||||||
|
|
||||||
const admin = em.create(Admin, {
|
|
||||||
phone: normalizedPhone,
|
phone: normalizedPhone,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
role
|
role,
|
||||||
})
|
});
|
||||||
|
await this.em.persistAndFlush(admin);
|
||||||
const notificationPreference = em.create(NotificationPreference, {
|
return admin;
|
||||||
admin,
|
|
||||||
events: notificationPrefrences,
|
|
||||||
})
|
|
||||||
|
|
||||||
await this.em.persistAndFlush([admin, notificationPreference]);
|
|
||||||
|
|
||||||
return admin
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByPhone(phone: string): Promise<Admin | null> {
|
async findByPhone(phone: string): Promise<Admin | null> {
|
||||||
@@ -103,17 +88,6 @@ export class AdminService {
|
|||||||
admin.lastName = rest.lastName;
|
admin.lastName = rest.lastName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rest.notificationPrefrences) {
|
|
||||||
//
|
|
||||||
const nf = await this.em.findOne(NotificationPreference, {
|
|
||||||
admin
|
|
||||||
})
|
|
||||||
if (!nf) {
|
|
||||||
throw new BadRequestException('Notification prefrences not found!')
|
|
||||||
}
|
|
||||||
nf.events = rest.notificationPrefrences
|
|
||||||
await this.em.flush()
|
|
||||||
}
|
|
||||||
if (rest.phone !== undefined && rest.phone !== admin.phone) {
|
if (rest.phone !== undefined && rest.phone !== admin.phone) {
|
||||||
const normalizedPhone = normalizePhone(rest.phone);
|
const normalizedPhone = normalizePhone(rest.phone);
|
||||||
const exists = await this.adminRepository.findOne({ phone: normalizedPhone });
|
const exists = await this.adminRepository.findOne({ phone: normalizedPhone });
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { Controller, Get, Body, Param, UseGuards, Query, Patch, Put, Post } from '@nestjs/common';
|
import { Controller, Get, Body, Param, UseGuards, Query, Patch, Put, Post } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiHeader } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiHeader } from '@nestjs/swagger';
|
||||||
import { NotificationService } from '../services/notification.service';
|
import { NotificationService } from '../services/notification.service';
|
||||||
import { NotificationPreferenceService } from '../services/notification-preference.service';
|
|
||||||
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 { UpdatePreferenceDto } from '../dto/update-preference.dto';
|
|
||||||
import { CreatePreferenceDto } from '../dto/create-preference.dto';
|
|
||||||
import { AdminId } from 'src/common/decorators/admin-id.decorator';
|
import { AdminId } from 'src/common/decorators/admin-id.decorator';
|
||||||
import { NotificationMessage } from 'src/common/enums/message.enum';
|
import { NotificationMessage } from 'src/common/enums/message.enum';
|
||||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||||
@@ -17,7 +14,6 @@ import { PermissionEnum } from 'src/common/enums/permission.enum';
|
|||||||
export class NotificationsController {
|
export class NotificationsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly notificationService: NotificationService,
|
private readonly notificationService: NotificationService,
|
||||||
private readonly preferenceService: NotificationPreferenceService,
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// @UseGuards(AuthGuard)
|
// @UseGuards(AuthGuard)
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { IsNotEmpty, IsEnum, IsArray } from 'class-validator';
|
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { NotifEvent } 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: NotifEvent,
|
|
||||||
example: NotifEvent.NEW_ORDER,
|
|
||||||
})
|
|
||||||
@IsEnum(NotifEvent)
|
|
||||||
@IsNotEmpty()
|
|
||||||
title!: NotifEvent;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Notification type (SMS, PUSH, BOTH, or NONE)',
|
|
||||||
enum: NotifChannelEnum,
|
|
||||||
example: NotifChannelEnum.SMS,
|
|
||||||
})
|
|
||||||
@IsArray()
|
|
||||||
@IsEnum(NotifChannelEnum, { each: true })
|
|
||||||
channels!: NotifChannelEnum[];
|
|
||||||
}
|
|
||||||
@@ -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,15 +0,0 @@
|
|||||||
import { Entity, Property, ManyToOne, Unique, PrimaryKey, OneToOne, Cascade, OptionalProps } from '@mikro-orm/core';
|
|
||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
|
||||||
import { NotifEvent } from '../interfaces/notification.interface';
|
|
||||||
import { Admin } from 'src/modules/admin/entities/admin.entity';
|
|
||||||
|
|
||||||
@Entity({ tableName: 'notification_preferences' })
|
|
||||||
export class NotificationPreference extends BaseEntity {
|
|
||||||
[OptionalProps]?: 'createdAt' | 'deletedAt'
|
|
||||||
|
|
||||||
@OneToOne(() => Admin, { owner: true, unique: true, cascade: [Cascade.PERSIST, Cascade.REMOVE] })
|
|
||||||
admin!: Admin;
|
|
||||||
|
|
||||||
@Property({ type: 'json', nullable: true })
|
|
||||||
events: NotifEvent[] = [];
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,7 @@ export enum NotifChannelEnum {
|
|||||||
SMS = 'sms',
|
SMS = 'sms',
|
||||||
PUSH = 'push',
|
PUSH = 'push',
|
||||||
}
|
}
|
||||||
export enum NotifTypeEnum {
|
|
||||||
TRANSACTIONAL = 'transactional',
|
|
||||||
PROMOTIONAL = 'promotional',
|
|
||||||
SYSTEM = 'system',
|
|
||||||
}
|
|
||||||
export enum NotifEvent {
|
export enum NotifEvent {
|
||||||
NEW_REQUEST = 'newRequest',
|
NEW_REQUEST = 'newRequest',
|
||||||
INVOICED = 'invoiced',
|
INVOICED = 'invoiced',
|
||||||
@@ -25,28 +21,13 @@ export interface NotifRequestMessage {
|
|||||||
templateId: string;
|
templateId: string;
|
||||||
parameters?: Record<string, string>;
|
parameters?: Record<string, string>;
|
||||||
};
|
};
|
||||||
pushNotif: {
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
icon: string;
|
|
||||||
action: {
|
|
||||||
type: string; //view order
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotifRequest {
|
export interface NotifRequest {
|
||||||
// requestId: string;
|
|
||||||
// timestamp: Date;
|
|
||||||
// notifType: NotifTypeEnum;
|
// notifType: NotifTypeEnum;
|
||||||
// channels: NotifChannelEnum[];
|
channels: NotifChannelEnum[];
|
||||||
recipients: recipientType[];
|
recipients: recipientType[];
|
||||||
message: NotifRequestMessage;
|
message: NotifRequestMessage;
|
||||||
metadata: {
|
|
||||||
priority: number;
|
|
||||||
// retries: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
//************************************************ */
|
//************************************************ */
|
||||||
interface INotifySmsPayload {
|
interface INotifySmsPayload {
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import { MikroOrmModule } from '@mikro-orm/nestjs';
|
|||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { Notification } from './entities/notification.entity';
|
import { Notification } from './entities/notification.entity';
|
||||||
import { NotificationPreference } from './entities/notification-preference.entity';
|
|
||||||
import { NotificationService } from './services/notification.service';
|
import { NotificationService } from './services/notification.service';
|
||||||
import { NotificationPreferenceService } from './services/notification-preference.service';
|
|
||||||
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 { SmsProcessor } from './processors/sms.processor';
|
import { SmsProcessor } from './processors/sms.processor';
|
||||||
@@ -31,7 +29,7 @@ import { SmsListeners } from './listeners/sms.listeners';
|
|||||||
imports: [
|
imports: [
|
||||||
forwardRef(() => AuthModule),
|
forwardRef(() => AuthModule),
|
||||||
JwtModule,
|
JwtModule,
|
||||||
MikroOrmModule.forFeature([Notification, NotificationPreference, User, SmsLog]),
|
MikroOrmModule.forFeature([Notification, User, SmsLog]),
|
||||||
BullModule.registerQueue(
|
BullModule.registerQueue(
|
||||||
{ name: NotificationQueueNameEnum.SMS },
|
{ name: NotificationQueueNameEnum.SMS },
|
||||||
{ name: NotificationQueueNameEnum.PUSH },
|
{ name: NotificationQueueNameEnum.PUSH },
|
||||||
@@ -54,7 +52,6 @@ import { SmsListeners } from './listeners/sms.listeners';
|
|||||||
controllers: [NotificationsController],
|
controllers: [NotificationsController],
|
||||||
providers: [
|
providers: [
|
||||||
NotificationService,
|
NotificationService,
|
||||||
NotificationPreferenceService,
|
|
||||||
NotificationQueueService,
|
NotificationQueueService,
|
||||||
PushNotificationService,
|
PushNotificationService,
|
||||||
PushProcessor,
|
PushProcessor,
|
||||||
@@ -67,7 +64,6 @@ import { SmsListeners } from './listeners/sms.listeners';
|
|||||||
SmsLogRepository,
|
SmsLogRepository,
|
||||||
SmsListeners,
|
SmsListeners,
|
||||||
],
|
],
|
||||||
exports: [NotificationService, NotificationPreferenceService,
|
exports: [NotificationService, NotificationQueueService, PushNotificationService, SmsService],
|
||||||
NotificationQueueService, PushNotificationService, SmsService],
|
|
||||||
})
|
})
|
||||||
export class NotificationsModule { }
|
export class NotificationsModule { }
|
||||||
|
|||||||
@@ -9,5 +9,48 @@ export class SmsLogRepository extends EntityRepository<SmsLog> {
|
|||||||
super(em, SmsLog);
|
super(em, SmsLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSmsCountByPhone(
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 10,
|
||||||
|
): Promise<PaginatedResult<{ phone: string; smsCount: number }>> {
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const knex = this.em.getConnection().getKnex();
|
||||||
|
|
||||||
|
const countResult = await knex('sms_logs')
|
||||||
|
.countDistinct('phone as total')
|
||||||
|
.first();
|
||||||
|
const total = Number(countResult?.total ?? 0);
|
||||||
|
|
||||||
|
const rows = await knex('sms_logs')
|
||||||
|
.select('phone')
|
||||||
|
.count('* as smsCount')
|
||||||
|
.groupBy('phone')
|
||||||
|
.orderBy('smsCount', 'desc')
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const data = rows.map((row: Record<string, unknown>) => ({
|
||||||
|
phone: String(row.phone ?? ''),
|
||||||
|
smsCount: Number(row.smsCount ?? row.sms_count ?? 0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit) || 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTotalSmsCount(): Promise<number> {
|
||||||
|
const result = await this.em
|
||||||
|
.createQueryBuilder(SmsLog, 's')
|
||||||
|
.select('count(*)', true)
|
||||||
|
.execute('get');
|
||||||
|
return Number(result ?? 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
|
||||||
import { NotificationPreference } from '../entities/notification-preference.entity';
|
|
||||||
import { CreatePreferenceDto } from '../dto/create-preference.dto';
|
|
||||||
import { UpdatePreferenceDto } from '../dto/update-preference.dto';
|
|
||||||
import { NotifEvent } from '../interfaces/notification.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class NotificationPreferenceService {
|
|
||||||
constructor(private readonly em: EntityManager) { }
|
|
||||||
|
|
||||||
// async create( dto: CreatePreferenceDto): Promise<NotificationPreference> {
|
|
||||||
// const restaurant = await this.em.findOne(Restaurant, { id: });
|
|
||||||
// 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(: string): Promise<NotificationPreference[]> {
|
|
||||||
// return this.em.find(NotificationPreference, {
|
|
||||||
// restaurant: { id: },
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
// async findByRestaurantAndType(: string, title: NotifTitleEnum): Promise<NotificationPreference | null> {
|
|
||||||
// return this.em.findOne(NotificationPreference, {
|
|
||||||
// restaurant: { id: },
|
|
||||||
// title,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
// async updateEnabled(
|
|
||||||
// : string,
|
|
||||||
// notificationType: string,
|
|
||||||
// enabled: boolean,
|
|
||||||
// ): Promise<NotificationPreference> {
|
|
||||||
// const preference = await this.findByRestaurantAndType(, notificationType);
|
|
||||||
// if (!preference) {
|
|
||||||
// throw new NotFoundException('Notification preference not found');
|
|
||||||
// }
|
|
||||||
|
|
||||||
// preference.enabled = enabled;
|
|
||||||
// await this.em.persistAndFlush(preference);
|
|
||||||
// return preference;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// async updatePreference(
|
|
||||||
// preferenceId: string,
|
|
||||||
// : string,
|
|
||||||
// dto: UpdatePreferenceDto,
|
|
||||||
// ): Promise<NotificationPreference> {
|
|
||||||
// const preference = await this.em.findOne(NotificationPreference, {
|
|
||||||
// id: preferenceId,
|
|
||||||
// restaurant: { id: },
|
|
||||||
// });
|
|
||||||
// if (!preference) {
|
|
||||||
// throw new NotFoundException('Notification preference not found');
|
|
||||||
// }
|
|
||||||
|
|
||||||
// this.em.assign(preference, dto);
|
|
||||||
// await this.em.persistAndFlush(preference);
|
|
||||||
// return preference;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// async delete(: string, preferenceId: string): Promise<void> {
|
|
||||||
// const preference = await this.em.findOne(NotificationPreference, {
|
|
||||||
// restaurant: { id: },
|
|
||||||
// id: preferenceId,
|
|
||||||
// });
|
|
||||||
// if (!preference) {
|
|
||||||
// throw new NotFoundException('Notification preference not found');
|
|
||||||
// }
|
|
||||||
// await this.em.removeAndFlush(preference);
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { EntityManager, FilterQuery } from '@mikro-orm/postgresql';
|
import { EntityManager, FilterQuery } from '@mikro-orm/postgresql';
|
||||||
import { Notification } from '../entities/notification.entity';
|
import { Notification } from '../entities/notification.entity';
|
||||||
import { NotificationPreferenceService } from './notification-preference.service';
|
|
||||||
import { NotificationQueueService } from './notification-queue.service';
|
import { NotificationQueueService } from './notification-queue.service';
|
||||||
import { NotificationsGateway } from '../notifications.gateway';
|
import { NotificationsGateway } from '../notifications.gateway';
|
||||||
import { NotifChannelEnum, NotifRequest, NotifEvent } from '../interfaces/notification.interface';
|
import { NotifChannelEnum, NotifRequest, NotifEvent } from '../interfaces/notification.interface';
|
||||||
import { SmsLog } from '../entities/smsLogs.entity';
|
|
||||||
import { SmsLogRepository } from '../repositories/sms-log.repository';
|
import { SmsLogRepository } from '../repositories/sms-log.repository';
|
||||||
import { PaginatedResult } from '../../../common/interfaces/pagination.interface';
|
import { Admin } from '../../admin/entities/admin.entity';
|
||||||
|
import { User } from '../../user/entities/user.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationService {
|
export class NotificationService {
|
||||||
@@ -15,214 +14,189 @@ export class NotificationService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
private readonly preferenceService: NotificationPreferenceService,
|
|
||||||
private readonly queueService: NotificationQueueService,
|
private readonly queueService: NotificationQueueService,
|
||||||
private readonly notificationGateway: NotificationsGateway,
|
private readonly notificationGateway: NotificationsGateway,
|
||||||
private readonly smsLogRepository: SmsLogRepository,
|
private readonly smsLogRepository: SmsLogRepository,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// async sendNotification(params: NotifRequest): Promise<Notification[]> {
|
async sendNotification(params: NotifRequest): Promise<Notification[]> {
|
||||||
// const { recipients, message, metadata, } = params;
|
const { recipients, message, channels } = params;
|
||||||
|
|
||||||
// // create Database notifications
|
// Create database notifications
|
||||||
// const notifications = await this.createAdminBulkNotifications(
|
const notifData = recipients.map(recipient => ({
|
||||||
// recipients.map(recipient => ({
|
title: message.title,
|
||||||
// title: message.title,
|
content: message.content,
|
||||||
// content: message.content,
|
adminId: 'adminId' in recipient ? recipient.adminId : null,
|
||||||
// adminId: 'adminId' in recipient ? recipient.adminId : null,
|
userId: 'userId' in recipient ? recipient.userId : null,
|
||||||
// userId: 'userId' in recipient ? recipient.userId : null,
|
}))
|
||||||
// })),
|
|
||||||
// );
|
|
||||||
|
|
||||||
// // get admin prefrences
|
const notifications = await this.createAdminBulkNotifications(notifData);
|
||||||
// const preference = await this.preferenceService.findByRestaurantAndType(, message.title);
|
|
||||||
|
|
||||||
// if(preference?.channels?.length === 0) {
|
if (channels.length === 0) {
|
||||||
// this.logger.warn(`Notification type is NONE for restaurant ${}, title ${message.title}`);
|
this.logger.warn(`No notification channels for title ${message.title}`);
|
||||||
// return notifications;
|
return notifications;
|
||||||
// }
|
}
|
||||||
|
|
||||||
// // send in app notification
|
// Send in-app notifications (only for admin recipients)
|
||||||
// if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) {
|
if (channels.includes(NotifChannelEnum.IN_APP)) {
|
||||||
// await this.queueService.addBulkInAppNotifications(
|
|
||||||
// notifications.map(notification => ({
|
|
||||||
// recipient: { adminId: notification.admin?.id || '', },
|
|
||||||
// subject: message.title,
|
|
||||||
// body: message.content,
|
|
||||||
// notificationId: notification.id,
|
|
||||||
// })),
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // add sms notifications to queue
|
const adminNotifications = notifications
|
||||||
// if (preference?.channels?.includes(NotifChannelEnum.SMS)) {
|
.filter(n => n.admin?.id)
|
||||||
// await this.queueService.addBulkSmsNotifications(
|
.map(notification => ({
|
||||||
// recipients.map(recipient => ({
|
recipient: { adminId: notification.admin!.id },
|
||||||
// recipient,
|
subject: message.title,
|
||||||
// templateId: message.sms.templateId,
|
body: message.content,
|
||||||
// parameters: message.sms.parameters,
|
notificationId: notification.id,
|
||||||
// })),
|
}))
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
// this.logger.log(`Queued notification for restaurant ${}, title ${message.title}`);
|
if (adminNotifications.length > 0) {
|
||||||
|
await this.queueService.addBulkInAppNotifications(adminNotifications);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// return notifications;
|
// Add SMS notifications to queue
|
||||||
// }
|
if (channels.includes(NotifChannelEnum.SMS) && message.sms?.templateId) {
|
||||||
|
await this.queueService.addBulkSmsNotifications(
|
||||||
|
recipients.map(recipient => ({
|
||||||
|
recipient,
|
||||||
|
templateId: message.sms.templateId,
|
||||||
|
parameters: message.sms.parameters,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// async createAdminBulkNotifications(
|
this.logger.log(`Queued notification for title ${message.title}`);
|
||||||
// params: {
|
return notifications;
|
||||||
|
}
|
||||||
|
|
||||||
// title: NotifTitleEnum;
|
async createAdminBulkNotifications(
|
||||||
// content: string;
|
params: {
|
||||||
// adminId: string | null;
|
title: NotifEvent;
|
||||||
// userId: string | null;
|
content: string;
|
||||||
// }[],
|
adminId: string | null;
|
||||||
// ): Promise < Notification[] > {
|
userId: string | null;
|
||||||
// const notifications = params.map(param => {
|
}[],
|
||||||
// return this.em.create(Notification, {
|
): Promise<Notification[]> {
|
||||||
// admin: param.adminId,
|
const notifications = params.map(param => {
|
||||||
// user: param.userId && param.userId.trim() !== '' ? param.userId : null,
|
return this.em.create(Notification, {
|
||||||
// title: param.title,
|
admin: param.adminId ? this.em.getReference(Admin, param.adminId) : undefined,
|
||||||
// content: param.content,
|
user: param.userId && param.userId.trim() !== '' ? this.em.getReference(User, param.userId) : undefined,
|
||||||
// });
|
title: param.title,
|
||||||
// });
|
content: param.content,
|
||||||
// await this.em.persistAndFlush(notifications);
|
});
|
||||||
// return notifications;
|
});
|
||||||
// }
|
await this.em.persistAndFlush(notifications);
|
||||||
|
return notifications;
|
||||||
|
}
|
||||||
|
|
||||||
// async findOne(id: string): Promise < Notification > {
|
async findAdminNotifications(
|
||||||
// const notification = await this.em.findOne(Notification, { id }, { populate: ['restaurant', 'user'] });
|
adminId: string,
|
||||||
// if(!notification) {
|
limit = 50,
|
||||||
// throw new NotFoundException('Notification not found');
|
cursor?: string,
|
||||||
// }
|
status?: 'seen' | 'unseen',
|
||||||
// return notification;
|
): Promise<{ data: Notification[]; nextCursor: string | null }> {
|
||||||
// }
|
const where: FilterQuery<Notification> = {
|
||||||
|
admin: { id: adminId },
|
||||||
|
};
|
||||||
|
|
||||||
// async findByRestaurant(
|
if (status === 'seen') {
|
||||||
// : string,
|
where.seenAt = { $ne: null };
|
||||||
// adminId: string,
|
} else if (status === 'unseen') {
|
||||||
// limit = 50,
|
where.seenAt = null;
|
||||||
// cursor ?: string,
|
}
|
||||||
// status ?: 'seen' | 'unseen',
|
|
||||||
// ): Promise < { data: Notification[]; nextCursor: string | null } > {
|
|
||||||
// const where: FilterQuery<Notification> = {
|
|
||||||
// restaurant: { id: },
|
|
||||||
// admin: { id: adminId },
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Filter by status (seen/unseen)
|
if (cursor) {
|
||||||
// if (status === 'seen') {
|
where.id = { $lt: cursor };
|
||||||
// where.seenAt = { $ne: null };
|
}
|
||||||
// } else if (status === 'unseen') {
|
|
||||||
// where.seenAt = null;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Cursor-based pagination: if cursor is provided, get items with id < cursor
|
const notifications = await this.em.find(Notification, where, {
|
||||||
// if (cursor) {
|
orderBy: { createdAt: 'DESC', id: 'DESC' },
|
||||||
// where.id = { $lt: cursor };
|
limit: limit + 1,
|
||||||
// }
|
populate: ['user'],
|
||||||
|
});
|
||||||
|
|
||||||
// const notifications = await this.em.find(Notification, where, {
|
const hasNextPage = notifications.length > limit;
|
||||||
// orderBy: { createdAt: 'DESC', id: 'DESC' },
|
const data = hasNextPage ? notifications.slice(0, limit) : notifications;
|
||||||
// limit: limit + 1, // fetch one extra to determine next page
|
const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
|
||||||
// populate: ['user'],
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const hasNextPage = notifications.length > limit;
|
return { data, nextCursor: nextCursor ?? null };
|
||||||
// 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(
|
async findUserNotifications(
|
||||||
// userId: string,
|
userId: string,
|
||||||
// : string,
|
limit = 50,
|
||||||
// limit = 50,
|
cursor?: string,
|
||||||
// cursor ?: string,
|
status?: 'seen' | 'unseen',
|
||||||
// status ?: 'seen' | 'unseen',
|
): Promise<{ data: Notification[]; nextCursor: string | null }> {
|
||||||
// ): Promise < { data: Notification[]; nextCursor: string | null } > {
|
const where: FilterQuery<Notification> = {
|
||||||
// const where: FilterQuery<Notification> = {
|
user: { id: userId },
|
||||||
// user: { id: userId },
|
};
|
||||||
// restaurant: { id: },
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Filter by status (seen/unseen)
|
if (status === 'seen') {
|
||||||
// if (status === 'seen') {
|
where.seenAt = { $ne: null };
|
||||||
// where.seenAt = { $ne: null };
|
} else if (status === 'unseen') {
|
||||||
// } else if (status === 'unseen') {
|
where.seenAt = null;
|
||||||
// where.seenAt = null;
|
}
|
||||||
// }
|
|
||||||
|
|
||||||
// // Cursor-based pagination: if cursor is provided, get items with id < cursor (since ULIDs are time-ordered)
|
if (cursor) {
|
||||||
// if (cursor) {
|
where.id = { $lt: cursor };
|
||||||
// where.id = { $lt: cursor };
|
}
|
||||||
// }
|
|
||||||
|
|
||||||
// const notifications = await this.em.find(Notification, where, {
|
const notifications = await this.em.find(Notification, where, {
|
||||||
// orderBy: { createdAt: 'DESC', id: 'DESC' },
|
orderBy: { createdAt: 'DESC', id: 'DESC' },
|
||||||
// limit: limit + 1, // Fetch one extra to determine if there's a next page
|
limit: limit + 1,
|
||||||
// });
|
});
|
||||||
|
|
||||||
// // Check if there's a next page
|
const hasNextPage = notifications.length > limit;
|
||||||
// const hasNextPage = notifications.length > limit;
|
const data = hasNextPage ? notifications.slice(0, limit) : notifications;
|
||||||
// const data = hasNextPage ? notifications.slice(0, limit) : notifications;
|
const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
|
||||||
// const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
|
|
||||||
|
|
||||||
// return {
|
return { data, nextCursor: nextCursor ?? null };
|
||||||
// data,
|
}
|
||||||
// nextCursor: nextCursor ?? null,
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// async readNotificationAdmin(id: string, adminId: string, : string): Promise < void> {
|
|
||||||
// const notification = await this.em.findOne(Notification, {
|
|
||||||
// id,
|
|
||||||
// admin: { id: adminId },
|
|
||||||
// restaurant: { id: },
|
|
||||||
// });
|
|
||||||
// if(!notification) {
|
|
||||||
// throw new NotFoundException('Notification not found');
|
|
||||||
// }
|
|
||||||
// notification.seenAt = new Date();
|
|
||||||
// await this.em.persistAndFlush(notification);
|
|
||||||
// }
|
|
||||||
// async readNotificationAsUser(id: string, userId: string, : string): Promise < void> {
|
|
||||||
// const notification = await this.em.findOne(Notification, {
|
|
||||||
// id,
|
|
||||||
// user: { id: userId },
|
|
||||||
// restaurant: { id: },
|
|
||||||
// });
|
|
||||||
// if(!notification) {
|
|
||||||
// throw new NotFoundException('Notification not found');
|
|
||||||
// }
|
|
||||||
// notification.seenAt = new Date();
|
|
||||||
// await this.em.persistAndFlush(notification);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// async findByRestaurantAndType(: string, title: NotifTitleEnum, limit = 50): Promise < Notification[] > {
|
async readNotificationAdmin(id: string, adminId: string): Promise<void> {
|
||||||
|
const notification = await this.em.findOne(Notification, {
|
||||||
|
id,
|
||||||
|
admin: { id: adminId },
|
||||||
|
});
|
||||||
|
if (!notification) {
|
||||||
|
throw new NotFoundException('Notification not found');
|
||||||
|
}
|
||||||
|
notification.seenAt = new Date();
|
||||||
|
await this.em.persistAndFlush(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async readNotificationAsUser(id: string, userId: string): Promise<void> {
|
||||||
|
const notification = await this.em.findOne(Notification, {
|
||||||
|
id,
|
||||||
|
user: { id: userId },
|
||||||
|
});
|
||||||
|
if (!notification) {
|
||||||
|
throw new NotFoundException('Notification not found');
|
||||||
|
}
|
||||||
|
notification.seenAt = new Date();
|
||||||
|
await this.em.persistAndFlush(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
// async findByTitle(title: NotifEvent, limit = 50): Promise<Notification[]> {
|
||||||
// return this.em.find(
|
// return this.em.find(
|
||||||
// Notification,
|
// Notification,
|
||||||
// {
|
// { title },
|
||||||
// restaurant: { id: },
|
|
||||||
// title,
|
|
||||||
// },
|
|
||||||
// {
|
// {
|
||||||
// orderBy: { createdAt: 'DESC' },
|
// orderBy: { createdAt: 'DESC' },
|
||||||
// limit,
|
// limit,
|
||||||
// populate: ['user'],
|
// populate: ['user', 'admin'],
|
||||||
// },
|
// },
|
||||||
// );
|
// );
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// async findByAdminAndRestaurant(adminId: string, : string, limit = 50): Promise < Notification[] > {
|
// async findByAdminAndTitle(adminId: string, title: NotifEvent, limit = 50): Promise<Notification[]> {
|
||||||
// return this.em.find(
|
// return this.em.find(
|
||||||
// Notification,
|
// Notification,
|
||||||
// { admin: { id: adminId }, restaurant: { id: } },
|
// { admin: { id: adminId }, title },
|
||||||
// {
|
// {
|
||||||
// orderBy: { createdAt: 'DESC' },
|
// orderBy: { createdAt: 'DESC' },
|
||||||
// limit,
|
// limit,
|
||||||
@@ -230,50 +204,45 @@ export class NotificationService {
|
|||||||
// );
|
// );
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// async countUnseenByUserAndRestaurant(userId: string, : string): Promise < number > {
|
|
||||||
// const where: FilterQuery<Notification> = {
|
|
||||||
// user: { id: userId },
|
|
||||||
// restaurant: { id: },
|
|
||||||
// seenAt: null,
|
|
||||||
// };
|
|
||||||
// return this.em.count(Notification, where);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// async countUnseenByRestaurant(adminId: string, : string): Promise < number > {
|
async countUnseenByUser(userId: string): Promise<number> {
|
||||||
// const where: FilterQuery<Notification> = {
|
return this.em.count(Notification, {
|
||||||
// admin: { id: adminId },
|
user: { id: userId },
|
||||||
// restaurant: { id: },
|
seenAt: null,
|
||||||
// seenAt: null,
|
});
|
||||||
// };
|
}
|
||||||
// return this.em.count(Notification, where);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// readAllNotifsAsUser(userId: string, : string): Promise < number > {
|
|
||||||
// const where: FilterQuery<Notification> = {
|
|
||||||
// user: { id: userId },
|
|
||||||
// restaurant: { id: },
|
|
||||||
// seenAt: null,
|
|
||||||
// };
|
|
||||||
// return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
|
|
||||||
// }
|
|
||||||
|
|
||||||
// readAllNotifsAsAdmin(adminId: string, : string): Promise < number > {
|
async countUnseenByAdmin(adminId: string): Promise<number> {
|
||||||
// const where: FilterQuery<Notification> = {
|
return this.em.count(Notification, {
|
||||||
// admin: { id: adminId },
|
admin: { id: adminId },
|
||||||
// restaurant: { id: },
|
seenAt: null,
|
||||||
// seenAt: null,
|
});
|
||||||
// };
|
}
|
||||||
// return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
|
|
||||||
// }
|
|
||||||
|
async readAllNotifsAsUser(userId: string): Promise<number> {
|
||||||
|
const notifications = await this.em.find(Notification, {
|
||||||
|
user: { id: userId },
|
||||||
|
seenAt: null,
|
||||||
|
});
|
||||||
|
const now = new Date();
|
||||||
|
notifications.forEach(n => { n.seenAt = now; });
|
||||||
|
await this.em.persistAndFlush(notifications);
|
||||||
|
return notifications.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async readAllNotifsAsAdmin(adminId: string): Promise<number> {
|
||||||
|
const notifications = await this.em.find(Notification, {
|
||||||
|
admin: { id: adminId },
|
||||||
|
seenAt: null,
|
||||||
|
});
|
||||||
|
const now = new Date();
|
||||||
|
notifications.forEach(n => { n.seenAt = now; });
|
||||||
|
await this.em.persistAndFlush(notifications);
|
||||||
|
return notifications.length;
|
||||||
|
}
|
||||||
|
|
||||||
// async getSmsCountByRestaurant(
|
|
||||||
// page: number = 1,
|
|
||||||
// limit: number = 10,
|
|
||||||
// ): Promise < PaginatedResult < { : string; restaurantName: string; smsCount: number } >> {
|
|
||||||
// return this.smsLogRepository.getSmsCountByRestaurant(page, limit);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// async getSmsCountBy(: string): Promise < number > {
|
|
||||||
// return this.smsLogRepository.getSmsCountBy();
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import * as admin from 'firebase-admin';
|
|||||||
export interface PushNotificationPayload {
|
export interface PushNotificationPayload {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
data?: Record<string, any>;
|
data?: Record<string, unknown>;
|
||||||
token?: string;
|
token?: string;
|
||||||
tokens?: string[];
|
tokens?: string[];
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,6 @@ export class PushNotificationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the service account JSON
|
|
||||||
const serviceAccount = JSON.parse(firebaseConfig);
|
const serviceAccount = JSON.parse(firebaseConfig);
|
||||||
|
|
||||||
if (!this.firebaseApp) {
|
if (!this.firebaseApp) {
|
||||||
@@ -134,7 +133,7 @@ export class PushNotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private convertDataToString(data: Record<string, any>): Record<string, string> {
|
private convertDataToString(data: Record<string, unknown>): Record<string, string> {
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
for (const [key, value] of Object.entries(data)) {
|
for (const [key, value] of Object.entries(data)) {
|
||||||
result[key] = typeof value === 'string' ? value : JSON.stringify(value);
|
result[key] = typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { NotifChannelEnum, NotifEvent } from '../../modules/notification/interfaces/notification.interface';
|
|
||||||
|
|
||||||
export interface NotificationPreferenceData {
|
|
||||||
title: NotifEvent;
|
|
||||||
channels: NotifChannelEnum[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const notificationPreferencesData: NotificationPreferenceData[] = [
|
|
||||||
// {
|
|
||||||
// title: NotifEvent.PAGER_CREATED,
|
|
||||||
// channels: [NotifChannelEnum.SMS, NotifChannelEnum.PUSH, NotifChannelEnum.IN_APP],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: NotifEvent.ORDER_CREATED,
|
|
||||||
// channels: [NotifChannelEnum.SMS, NotifChannelEnum.PUSH, NotifChannelEnum.IN_APP],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: NotifEvent.PAYMENT_SUCCESS,
|
|
||||||
// channels: [NotifChannelEnum.SMS, NotifChannelEnum.PUSH, NotifChannelEnum.IN_APP],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: NotifEvent.REVIEW_CREATED,
|
|
||||||
// channels: [NotifChannelEnum.SMS, NotifChannelEnum.PUSH, NotifChannelEnum.IN_APP],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: NotifEvent.ORDER_STATUS_CHANGED,
|
|
||||||
// channels: [NotifChannelEnum.SMS, NotifChannelEnum.PUSH, NotifChannelEnum.IN_APP],
|
|
||||||
// },
|
|
||||||
];
|
|
||||||
Reference in New Issue
Block a user