notification

This commit is contained in:
2026-07-09 20:58:19 +03:30
parent 0732964753
commit c485ee326c
21 changed files with 1402 additions and 1 deletions
+2
View File
@@ -22,6 +22,7 @@ import { PaymentsModule } from './payments/payments.module';
import { ProvincesModule } from './provinces/provinces.module'; import { ProvincesModule } from './provinces/provinces.module';
import { UploaderModule } from './uploader/uploader.module'; import { UploaderModule } from './uploader/uploader.module';
import { DashboardModule } from './dashboard/dashboard.module'; import { DashboardModule } from './dashboard/dashboard.module';
import { NotificationsModule } from './notifications/notifications.module';
@Module({ @Module({
imports: [ imports: [
@@ -49,6 +50,7 @@ import { DashboardModule } from './dashboard/dashboard.module';
ProvincesModule, ProvincesModule,
UploaderModule, UploaderModule,
DashboardModule, DashboardModule,
NotificationsModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [],
+2
View File
@@ -4,6 +4,7 @@ import { AuthGuardsModule } from '../auth/auth-guards.module';
import { AuthorizationModule } from '../authorization/authorization.module'; import { AuthorizationModule } from '../authorization/authorization.module';
import { SalonCustomer } from '../customers/entities/salon-customer.entity'; import { SalonCustomer } from '../customers/entities/salon-customer.entity';
import { Reserve } from '../reserves/entities/reserve.entity'; import { Reserve } from '../reserves/entities/reserve.entity';
import { NotificationsModule } from '../notifications/notifications.module';
import { Salon } from '../salons/entities/salon.entity'; import { Salon } from '../salons/entities/salon.entity';
import { SentMessage } from '../sent-messages/entities/sent-message.entity'; import { SentMessage } from '../sent-messages/entities/sent-message.entity';
import { SmsModule } from '../sms/sms.module'; import { SmsModule } from '../sms/sms.module';
@@ -28,6 +29,7 @@ import { CampaignRun } from './entities/campaign-run.entity';
]), ]),
SmsModule, SmsModule,
SmsSettingsModule, SmsSettingsModule,
NotificationsModule,
SubscriptionsModule, SubscriptionsModule,
AuthorizationModule, AuthorizationModule,
AuthGuardsModule, AuthGuardsModule,
+21 -1
View File
@@ -8,6 +8,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AuthUser } from '../auth/interfaces/auth-user.interface'; import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { AuthorizationService } from '../authorization/authorization.service'; import { AuthorizationService } from '../authorization/authorization.service';
import { SalonNotificationsService } from '../notifications/salon-notifications.service';
import { Salon } from '../salons/entities/salon.entity'; import { Salon } from '../salons/entities/salon.entity';
import { SentMessage } from '../sent-messages/entities/sent-message.entity'; import { SentMessage } from '../sent-messages/entities/sent-message.entity';
import { SentMessageStatus } from '../sent-messages/enums/sent-message-status.enum'; import { SentMessageStatus } from '../sent-messages/enums/sent-message-status.enum';
@@ -56,6 +57,7 @@ export class CampaignsService {
private readonly smsDispatchService: SmsDispatchService, private readonly smsDispatchService: SmsDispatchService,
private readonly smsService: SmsService, private readonly smsService: SmsService,
private readonly salonSubscriptionsService: SalonSubscriptionsService, private readonly salonSubscriptionsService: SalonSubscriptionsService,
private readonly salonNotificationsService: SalonNotificationsService,
private readonly authorizationService: AuthorizationService, private readonly authorizationService: AuthorizationService,
) {} ) {}
@@ -198,7 +200,7 @@ export class CampaignsService {
targetCount: number; targetCount: number;
estimatedRevenue: number; estimatedRevenue: number;
}): Promise<CampaignRun> { }): Promise<CampaignRun> {
return this.campaignRunsRepository.save( const run = await this.campaignRunsRepository.save(
this.campaignRunsRepository.create({ this.campaignRunsRepository.create({
salonId: params.salon.id, salonId: params.salon.id,
type: params.type, type: params.type,
@@ -211,6 +213,14 @@ export class CampaignsService {
triggeredByUserId: null, triggeredByUserId: null,
}), }),
); );
await this.salonNotificationsService.notifyCampaignFailed({
salon: params.salon,
type: params.type,
targetCount: params.targetCount,
});
return run;
} }
sumEstimatedRevenue(targets: CampaignTargetCustomer[]): number { sumEstimatedRevenue(targets: CampaignTargetCustomer[]): number {
@@ -352,6 +362,16 @@ export class CampaignsService {
: CampaignRunStatus.PARTIAL; : CampaignRunStatus.PARTIAL;
await this.campaignRunsRepository.save(run); await this.campaignRunsRepository.save(run);
await this.salonNotificationsService.notifyCampaignSent({
salon,
type: run.type,
campaignRunId: run.id,
sentCount,
failedCount,
targetCount: run.targetCount,
estimatedRevenue: run.estimatedRevenue,
});
} }
private ensureValidType(type: CampaignType) { private ensureValidType(type: CampaignType) {
@@ -0,0 +1,161 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableForeignKey,
TableIndex,
} from 'typeorm';
import { NOTIFICATION_SCENARIOS } from '../../notifications/constants/notification-scenarios.constants';
export class AddNotifications1721500000000 implements MigrationInterface {
name = 'AddNotifications1721500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE "notification_scenario_key_enum" AS ENUM (
'reserve_created',
'reserve_confirmed',
'reserve_cancelled',
'reserve_reminder_sent',
'campaign_sent',
'campaign_failed'
)
`);
await queryRunner.createTable(
new Table({
name: 'notification_scenario_settings',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'gen_random_uuid()',
},
{
name: 'key',
type: 'notification_scenario_key_enum',
isUnique: true,
},
{ name: 'title', type: 'varchar', length: '300', isNullable: true },
{ name: 'body', type: 'text', isNullable: true },
{ name: 'isActive', type: 'boolean', default: true },
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
],
}),
true,
);
await queryRunner.createIndex(
'notification_scenario_settings',
new TableIndex({
name: 'IDX_notification_scenario_settings_key',
columnNames: ['key'],
isUnique: true,
}),
);
await queryRunner.createTable(
new Table({
name: 'notifications',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'gen_random_uuid()',
},
{ name: 'salonId', type: 'uuid' },
{ name: 'userId', type: 'uuid' },
{
name: 'scenarioKey',
type: 'notification_scenario_key_enum',
},
{ name: 'title', type: 'varchar', length: '300' },
{ name: 'body', type: 'text' },
{ name: 'link', type: 'varchar', length: '500', isNullable: true },
{
name: 'relatedEntityType',
type: 'varchar',
length: '50',
isNullable: true,
},
{
name: 'relatedEntityId',
type: 'uuid',
isNullable: true,
},
{ name: 'isRead', type: 'boolean', default: false },
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
],
}),
true,
);
await queryRunner.createIndex(
'notifications',
new TableIndex({
name: 'IDX_notifications_salonId_createdAt',
columnNames: ['salonId', 'createdAt'],
}),
);
await queryRunner.createIndex(
'notifications',
new TableIndex({
name: 'IDX_notifications_userId_isRead_createdAt',
columnNames: ['userId', 'isRead', 'createdAt'],
}),
);
await queryRunner.createIndex(
'notifications',
new TableIndex({
name: 'IDX_notifications_scenarioKey',
columnNames: ['scenarioKey'],
}),
);
await queryRunner.createForeignKey(
'notifications',
new TableForeignKey({
name: 'FK_notifications_salonId',
columnNames: ['salonId'],
referencedTableName: 'salons',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'notifications',
new TableForeignKey({
name: 'FK_notifications_userId',
columnNames: ['userId'],
referencedTableName: 'users',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
for (const scenario of NOTIFICATION_SCENARIOS) {
await queryRunner.query(
`
INSERT INTO notification_scenario_settings (key, title, body, "isActive")
VALUES ($1, $2, $3, true)
`,
[scenario.key, scenario.defaultTitle, scenario.defaultBody],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('notifications', true);
await queryRunner.dropTable('notification_scenario_settings', true);
await queryRunner.query('DROP TYPE "notification_scenario_key_enum"');
}
}
@@ -0,0 +1,291 @@
import { NotificationScenarioKey } from '../enums/notification-scenario-key.enum';
export interface NotificationScenarioVariable {
key: string;
label: string;
description: string;
sampleValue: string;
}
export interface NotificationScenarioDefinition {
key: NotificationScenarioKey;
label: string;
description: string;
defaultTitle: string;
defaultBody: string;
variables: NotificationScenarioVariable[];
}
export const NOTIFICATION_SCENARIOS: NotificationScenarioDefinition[] = [
{
key: NotificationScenarioKey.RESERVE_CREATED,
label: 'ثبت رزرو جدید',
description: 'اعلان برای سالن هنگام ثبت رزرو جدید',
defaultTitle: 'رزرو جدید ثبت شد',
defaultBody:
'رزرو {customerName} برای {appointmentDate} ساعت {startTime} با {stylistName} ثبت شد.',
variables: [
{
key: 'customerName',
label: 'نام مشتری',
description: 'نام و نام خانوادگی مشتری',
sampleValue: 'مریم احمدی',
},
{
key: 'salonName',
label: 'نام سالن',
description: 'نام سالن زیبایی',
sampleValue: 'سالن زیبایی رز',
},
{
key: 'stylistName',
label: 'نام متخصص',
description: 'نام متخصص انجام‌دهنده خدمت',
sampleValue: 'سارا محمدی',
},
{
key: 'appointmentDate',
label: 'تاریخ نوبت',
description: 'تاریخ رزرو',
sampleValue: '۱۴۰۴/۰۴/۲۰',
},
{
key: 'startTime',
label: 'ساعت شروع',
description: 'ساعت شروع نوبت',
sampleValue: '۱۰:۰۰',
},
{
key: 'endTime',
label: 'ساعت پایان',
description: 'ساعت پایان نوبت',
sampleValue: '۱۱:۳۰',
},
{
key: 'totalAmount',
label: 'مبلغ کل',
description: 'مبلغ کل رزرو به تومان',
sampleValue: '۱,۵۰۰,۰۰۰',
},
],
},
{
key: NotificationScenarioKey.RESERVE_CONFIRMED,
label: 'تأیید رزرو',
description: 'اعلان برای سالن پس از پرداخت بیعانه و تأیید رزرو',
defaultTitle: 'رزرو تأیید شد',
defaultBody:
'رزرو {customerName} برای {appointmentDate} ساعت {startTime} تأیید شد. بیعانه: {depositAmount} تومان',
variables: [
{
key: 'customerName',
label: 'نام مشتری',
description: 'نام و نام خانوادگی مشتری',
sampleValue: 'مریم احمدی',
},
{
key: 'salonName',
label: 'نام سالن',
description: 'نام سالن زیبایی',
sampleValue: 'سالن زیبایی رز',
},
{
key: 'appointmentDate',
label: 'تاریخ نوبت',
description: 'تاریخ رزرو',
sampleValue: '۱۴۰۴/۰۴/۲۰',
},
{
key: 'startTime',
label: 'ساعت شروع',
description: 'ساعت شروع نوبت',
sampleValue: '۱۰:۰۰',
},
{
key: 'depositAmount',
label: 'مبلغ بیعانه',
description: 'مبلغ بیعانه به تومان',
sampleValue: '۵۰۰,۰۰۰',
},
{
key: 'totalAmount',
label: 'مبلغ کل',
description: 'مبلغ کل رزرو به تومان',
sampleValue: '۱,۵۰۰,۰۰۰',
},
],
},
{
key: NotificationScenarioKey.RESERVE_CANCELLED,
label: 'لغو رزرو',
description: 'اعلان برای سالن هنگام لغو رزرو',
defaultTitle: 'رزرو لغو شد',
defaultBody:
'رزرو {customerName} برای {appointmentDate} ساعت {startTime} لغو شد.',
variables: [
{
key: 'customerName',
label: 'نام مشتری',
description: 'نام و نام خانوادگی مشتری',
sampleValue: 'مریم احمدی',
},
{
key: 'salonName',
label: 'نام سالن',
description: 'نام سالن زیبایی',
sampleValue: 'سالن زیبایی رز',
},
{
key: 'appointmentDate',
label: 'تاریخ نوبت',
description: 'تاریخ رزرو',
sampleValue: '۱۴۰۴/۰۴/۲۰',
},
{
key: 'startTime',
label: 'ساعت شروع',
description: 'ساعت شروع نوبت',
sampleValue: '۱۰:۰۰',
},
{
key: 'cancellationReason',
label: 'دلیل لغو',
description: 'دلیل لغو رزرو (در صورت ثبت)',
sampleValue: 'درخواست مشتری',
},
],
},
{
key: NotificationScenarioKey.RESERVE_REMINDER_SENT,
label: 'ارسال یادآوری رزرو',
description:
'اعلان خلاصه برای سالن پس از ارسال پیامک‌های یادآوری نوبت‌های امروز',
defaultTitle: 'یادآوری نوبت‌ها ارسال شد',
defaultBody:
'{sentCount} پیامک یادآوری برای نوبت‌های امروز ({appointmentDate}) ارسال شد.',
variables: [
{
key: 'sentCount',
label: 'تعداد ارسال‌شده',
description: 'تعداد پیامک‌های یادآوری ارسال‌شده',
sampleValue: '۵',
},
{
key: 'appointmentDate',
label: 'تاریخ نوبت',
description: 'تاریخ روز یادآوری',
sampleValue: '۱۴۰۴/۰۴/۲۰',
},
{
key: 'salonName',
label: 'نام سالن',
description: 'نام سالن زیبایی',
sampleValue: 'سالن زیبایی رز',
},
],
},
{
key: NotificationScenarioKey.CAMPAIGN_SENT,
label: 'ارسال کمپین',
description: 'اعلان برای سالن پس از اجرای موفق یا جزئی کمپین پیامکی',
defaultTitle: 'کمپین «{campaignLabel}» ارسال شد',
defaultBody:
'{sentCount} از {targetCount} پیامک ارسال شد. درآمد تخمینی: {estimatedRevenue} تومان',
variables: [
{
key: 'campaignLabel',
label: 'عنوان کمپین',
description: 'نام نوع کمپین',
sampleValue: 'کمپین تولد مشتری',
},
{
key: 'campaignType',
label: 'نوع کمپین',
description: 'کد نوع کمپین',
sampleValue: 'birthday',
},
{
key: 'sentCount',
label: 'تعداد ارسال‌شده',
description: 'تعداد پیامک‌های موفق',
sampleValue: '۱۲',
},
{
key: 'failedCount',
label: 'تعداد ناموفق',
description: 'تعداد پیامک‌های ناموفق',
sampleValue: '۱',
},
{
key: 'targetCount',
label: 'تعداد هدف',
description: 'تعداد کل مخاطبان کمپین',
sampleValue: '۱۳',
},
{
key: 'estimatedRevenue',
label: 'درآمد تخمینی',
description: 'درآمد تخمینی به تومان',
sampleValue: '۸,۰۰۰,۰۰۰',
},
{
key: 'salonName',
label: 'نام سالن',
description: 'نام سالن زیبایی',
sampleValue: 'سالن زیبایی رز',
},
],
},
{
key: NotificationScenarioKey.CAMPAIGN_FAILED,
label: 'شکست کمپین',
description:
'اعلان برای سالن وقتی کمپین به‌دلیل کمبود اعتبار یا خطای ارسال اجرا نشود',
defaultTitle: 'کمپین «{campaignLabel}» ارسال نشد',
defaultBody:
'کمپین برای {targetCount} مشتری اجرا نشد. موجودی پیامک یا تنظیمات را بررسی کنید.',
variables: [
{
key: 'campaignLabel',
label: 'عنوان کمپین',
description: 'نام نوع کمپین',
sampleValue: 'کمپین بازگشت مشتریان',
},
{
key: 'campaignType',
label: 'نوع کمپین',
description: 'کد نوع کمپین',
sampleValue: 'returning_customers',
},
{
key: 'targetCount',
label: 'تعداد هدف',
description: 'تعداد کل مخاطبان کمپین',
sampleValue: '۸',
},
{
key: 'salonName',
label: 'نام سالن',
description: 'نام سالن زیبایی',
sampleValue: 'سالن زیبایی رز',
},
],
},
];
export const NOTIFICATION_SCENARIO_MAP = new Map(
NOTIFICATION_SCENARIOS.map((scenario) => [scenario.key, scenario]),
);
export function getNotificationSampleVariables(
key: NotificationScenarioKey,
): Record<string, string> {
const scenario = NOTIFICATION_SCENARIO_MAP.get(key);
if (!scenario) {
return {};
}
return Object.fromEntries(
scenario.variables.map((variable) => [variable.key, variable.sampleValue]),
);
}
@@ -0,0 +1,26 @@
import { Type, Transform } from 'class-transformer';
import { IsBoolean, IsInt, IsOptional, Max, Min } from 'class-validator';
export class FindNotificationsQueryDto {
@IsOptional()
@Transform(({ value }) => {
if (value === true || value === 'true' || value === '1') return true;
if (value === false || value === 'false' || value === '0') return false;
return undefined;
})
@IsBoolean()
unreadOnly?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
}
@@ -0,0 +1,39 @@
import {
IsBoolean,
IsEnum,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { NotificationScenarioKey } from '../enums/notification-scenario-key.enum';
export class UpdateNotificationScenarioSettingDto {
@IsOptional()
@IsString()
@MaxLength(300)
title?: string | null;
@IsOptional()
@IsString()
@MaxLength(1000)
body?: string | null;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class PreviewNotificationScenarioDto {
@IsEnum(NotificationScenarioKey)
key: NotificationScenarioKey;
@IsOptional()
@IsString()
@MaxLength(300)
title?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
body?: string;
}
@@ -0,0 +1,38 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { NotificationScenarioKey } from '../enums/notification-scenario-key.enum';
@Entity('notification_scenario_settings')
@Index(['key'], { unique: true })
export class NotificationScenarioSetting {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
type: 'enum',
enum: NotificationScenarioKey,
enumName: 'notification_scenario_key_enum',
})
key: NotificationScenarioKey;
@Column({ type: 'varchar', length: 300, nullable: true })
title: string | null;
@Column({ type: 'text', nullable: true })
body: string | null;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,67 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Salon } from '../../salons/entities/salon.entity';
import { User } from '../../users/entities/user.entity';
import { NotificationScenarioKey } from '../enums/notification-scenario-key.enum';
@Entity('notifications')
@Index(['salonId', 'createdAt'])
@Index(['userId', 'isRead', 'createdAt'])
@Index(['scenarioKey'])
export class Notification {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
salonId: string;
@ManyToOne(() => Salon, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'salonId' })
salon: Salon;
@Column()
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@Column({
type: 'enum',
enum: NotificationScenarioKey,
enumName: 'notification_scenario_key_enum',
})
scenarioKey: NotificationScenarioKey;
@Column({ length: 300 })
title: string;
@Column({ type: 'text' })
body: string;
@Column({ type: 'varchar', length: 500, nullable: true })
link: string | null;
@Column({ type: 'varchar', length: 50, nullable: true })
relatedEntityType: string | null;
@Column({ type: 'uuid', nullable: true })
relatedEntityId: string | null;
@Column({ default: false })
isRead: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,8 @@
export enum NotificationScenarioKey {
RESERVE_CREATED = 'reserve_created',
RESERVE_CONFIRMED = 'reserve_confirmed',
RESERVE_CANCELLED = 'reserve_cancelled',
RESERVE_REMINDER_SENT = 'reserve_reminder_sent',
CAMPAIGN_SENT = 'campaign_sent',
CAMPAIGN_FAILED = 'campaign_failed',
}
@@ -0,0 +1,135 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { renderSmsTemplate } from '../sms-templates/utils/render-sms-template.util';
import {
getNotificationSampleVariables,
NOTIFICATION_SCENARIO_MAP,
NOTIFICATION_SCENARIOS,
} from './constants/notification-scenarios.constants';
import { PreviewNotificationScenarioDto } from './dto/update-notification-scenario-setting.dto';
import { NotificationScenarioSetting } from './entities/notification-scenario-setting.entity';
import { Notification } from './entities/notification.entity';
import { NotificationScenarioKey } from './enums/notification-scenario-key.enum';
export interface CreateSalonNotificationParams {
salonId: string;
userId: string;
key: NotificationScenarioKey;
variables: Record<string, string>;
link?: string | null;
relatedEntityType?: string | null;
relatedEntityId?: string | null;
}
@Injectable()
export class NotificationDispatchService {
constructor(
@InjectRepository(NotificationScenarioSetting)
private readonly settingsRepository: Repository<NotificationScenarioSetting>,
@InjectRepository(Notification)
private readonly notificationsRepository: Repository<Notification>,
) {}
getScenarioDefinitions() {
return NOTIFICATION_SCENARIOS;
}
async findAllSettings(): Promise<NotificationScenarioSetting[]> {
return this.settingsRepository.find({
order: { key: 'ASC' },
});
}
async findSettingByKey(
key: NotificationScenarioKey,
): Promise<NotificationScenarioSetting> {
const setting = await this.settingsRepository.findOne({ where: { key } });
if (!setting) {
throw new NotFoundException(
`Notification scenario setting "${key}" not found`,
);
}
return setting;
}
async resolveContent(
key: NotificationScenarioKey,
variables: Record<string, string>,
): Promise<{ title: string; body: string } | null> {
const setting = await this.findSettingByKey(key);
if (!setting.isActive) {
return null;
}
const definition = NOTIFICATION_SCENARIO_MAP.get(key);
const titleTemplate = setting.title ?? definition?.defaultTitle ?? '';
const bodyTemplate = setting.body ?? definition?.defaultBody ?? '';
if (!titleTemplate && !bodyTemplate) {
return null;
}
return {
title: renderSmsTemplate(titleTemplate, variables),
body: renderSmsTemplate(bodyTemplate, variables),
};
}
async previewMessage(
dto: PreviewNotificationScenarioDto,
): Promise<{ title: string; body: string }> {
const definition = NOTIFICATION_SCENARIO_MAP.get(dto.key);
if (!definition) {
throw new NotFoundException(
`Notification scenario "${dto.key}" not found`,
);
}
const variables = getNotificationSampleVariables(dto.key);
const titleTemplate = dto.title ?? definition.defaultTitle;
const bodyTemplate = dto.body ?? definition.defaultBody;
if (dto.title === undefined && dto.body === undefined) {
const setting = await this.findSettingByKey(dto.key);
return {
title: renderSmsTemplate(
setting.title ?? definition.defaultTitle,
variables,
),
body: renderSmsTemplate(
setting.body ?? definition.defaultBody,
variables,
),
};
}
return {
title: renderSmsTemplate(titleTemplate, variables),
body: renderSmsTemplate(bodyTemplate, variables),
};
}
async createForSalon(
params: CreateSalonNotificationParams,
): Promise<Notification | null> {
const content = await this.resolveContent(params.key, params.variables);
if (!content) {
return null;
}
return this.notificationsRepository.save(
this.notificationsRepository.create({
salonId: params.salonId,
userId: params.userId,
scenarioKey: params.key,
title: content.title,
body: content.body,
link: params.link ?? null,
relatedEntityType: params.relatedEntityType ?? null,
relatedEntityId: params.relatedEntityId ?? null,
isRead: false,
}),
);
}
}
@@ -0,0 +1,79 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Permissions } from '../auth/decorators/permissions.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { PermissionsGuard } from '../auth/guards/permissions.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { PermissionCode } from '../authorization/enums/permission-code.enum';
import { UserRole } from '../users/enums/user-role.enum';
import {
ApiStandardController,
ApiStandardOkResponse,
} from '../swagger/decorators/swagger-response.decorator';
import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup';
import {
PreviewNotificationScenarioDto,
UpdateNotificationScenarioSettingDto,
} from './dto/update-notification-scenario-setting.dto';
import { NotificationScenarioKey } from './enums/notification-scenario-key.enum';
import { NotificationSettingsService } from './notification-settings.service';
@ApiTags('Notification Settings')
@ApiBearerAuth(SWAGGER_JWT_AUTH)
@ApiStandardController()
@Controller('notification-settings')
@UseGuards(RolesGuard, PermissionsGuard)
export class NotificationSettingsController {
constructor(
private readonly notificationSettingsService: NotificationSettingsService,
) {}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.SMS_TEMPLATES_READ)
@ApiStandardOkResponse(Object, { isArray: true })
@Get('scenarios')
getScenarios() {
return this.notificationSettingsService.getScenarioDefinitions();
}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.SMS_TEMPLATES_READ)
@ApiStandardOkResponse(Object, { isArray: true })
@Get()
findAll(@CurrentUser() user: AuthUser) {
return this.notificationSettingsService.findAll(user);
}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.SMS_TEMPLATES_WRITE)
@ApiStandardOkResponse(Object)
@Patch(':key')
update(
@Param('key') key: NotificationScenarioKey,
@Body() updateDto: UpdateNotificationScenarioSettingDto,
@CurrentUser() user: AuthUser,
) {
return this.notificationSettingsService.update(key, updateDto, user);
}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.SMS_TEMPLATES_READ)
@ApiStandardOkResponse(Object)
@Post('preview')
preview(
@Body() dto: PreviewNotificationScenarioDto,
@CurrentUser() user: AuthUser,
) {
return this.notificationSettingsService.preview(dto, user);
}
}
@@ -0,0 +1,130 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { ServicePolicy } from '../authorization/policies/service.policy';
import { renderSmsTemplate } from '../sms-templates/utils/render-sms-template.util';
import {
getNotificationSampleVariables,
NOTIFICATION_SCENARIO_MAP,
NotificationScenarioDefinition,
} from './constants/notification-scenarios.constants';
import {
PreviewNotificationScenarioDto,
UpdateNotificationScenarioSettingDto,
} from './dto/update-notification-scenario-setting.dto';
import { NotificationScenarioSetting } from './entities/notification-scenario-setting.entity';
import { NotificationScenarioKey } from './enums/notification-scenario-key.enum';
import { NotificationDispatchService } from './notification-dispatch.service';
export interface NotificationScenarioSettingResponse {
key: NotificationScenarioKey;
title: string | null;
body: string | null;
isActive: boolean;
definition: NotificationScenarioDefinition;
previewTitle: string;
previewBody: string;
}
@Injectable()
export class NotificationSettingsService {
constructor(
@InjectRepository(NotificationScenarioSetting)
private readonly settingsRepository: Repository<NotificationScenarioSetting>,
private readonly notificationDispatchService: NotificationDispatchService,
private readonly servicePolicy: ServicePolicy,
) {}
getScenarioDefinitions(): NotificationScenarioDefinition[] {
return this.notificationDispatchService.getScenarioDefinitions();
}
async findAll(user: AuthUser): Promise<NotificationScenarioSettingResponse[]> {
this.ensureCanManage(user);
const settings = await this.notificationDispatchService.findAllSettings();
return Promise.all(settings.map((setting) => this.toResponse(setting)));
}
async update(
key: NotificationScenarioKey,
updateDto: UpdateNotificationScenarioSettingDto,
user: AuthUser,
): Promise<NotificationScenarioSettingResponse> {
this.ensureCanManage(user);
if (!NOTIFICATION_SCENARIO_MAP.has(key)) {
throw new NotFoundException(`Notification scenario "${key}" not found`);
}
const setting =
await this.notificationDispatchService.findSettingByKey(key);
if (updateDto.title !== undefined) {
setting.title = updateDto.title;
}
if (updateDto.body !== undefined) {
setting.body = updateDto.body;
}
if (updateDto.isActive !== undefined) {
setting.isActive = updateDto.isActive;
}
const definition = NOTIFICATION_SCENARIO_MAP.get(key)!;
const effectiveTitle = setting.title ?? definition.defaultTitle;
const effectiveBody = setting.body ?? definition.defaultBody;
if (!effectiveTitle || !effectiveBody) {
throw new BadRequestException(
'Both title and body must be configured for notification scenarios',
);
}
const saved = await this.settingsRepository.save(setting);
return this.toResponse(saved);
}
async preview(
dto: PreviewNotificationScenarioDto,
user: AuthUser,
): Promise<{ title: string; body: string }> {
this.ensureCanManage(user);
return this.notificationDispatchService.previewMessage(dto);
}
private async toResponse(
setting: NotificationScenarioSetting,
): Promise<NotificationScenarioSettingResponse> {
const definition = NOTIFICATION_SCENARIO_MAP.get(setting.key);
if (!definition) {
throw new NotFoundException(
`Notification scenario "${setting.key}" not found`,
);
}
const variables = getNotificationSampleVariables(setting.key);
const titleTemplate = setting.title ?? definition.defaultTitle;
const bodyTemplate = setting.body ?? definition.defaultBody;
return {
key: setting.key,
title: setting.title,
body: setting.body,
isActive: setting.isActive,
definition,
previewTitle: renderSmsTemplate(titleTemplate, variables),
previewBody: renderSmsTemplate(bodyTemplate, variables),
};
}
private ensureCanManage(user: AuthUser): void {
if (!this.servicePolicy.canManage(user)) {
throw new ForbiddenException();
}
}
}
@@ -0,0 +1,63 @@
import {
Controller,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { UserRole } from '../users/enums/user-role.enum';
import {
ApiStandardController,
ApiStandardOkResponse,
} from '../swagger/decorators/swagger-response.decorator';
import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup';
import { FindNotificationsQueryDto } from './dto/find-notifications-query.dto';
import { Notification } from './entities/notification.entity';
import { NotificationsService } from './notifications.service';
@ApiTags('Notifications')
@ApiBearerAuth(SWAGGER_JWT_AUTH)
@ApiStandardController()
@Controller('notifications')
@UseGuards(RolesGuard)
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Roles(UserRole.SALON)
@ApiStandardOkResponse(Notification, { isArray: true })
@Get()
findMine(
@Query() query: FindNotificationsQueryDto,
@CurrentUser() user: AuthUser,
) {
return this.notificationsService.findForUser(user, query);
}
@Roles(UserRole.SALON)
@ApiStandardOkResponse(Object)
@Get('unread-count')
getUnreadCount(@CurrentUser() user: AuthUser) {
return this.notificationsService.getUnreadCount(user);
}
@Roles(UserRole.SALON)
@ApiStandardOkResponse(Notification)
@Patch(':id/read')
markAsRead(@Param('id') id: string, @CurrentUser() user: AuthUser) {
return this.notificationsService.markAsRead(id, user);
}
@Roles(UserRole.SALON)
@ApiStandardOkResponse(Object)
@Post('read-all')
markAllAsRead(@CurrentUser() user: AuthUser) {
return this.notificationsService.markAllAsRead(user);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthGuardsModule } from '../auth/auth-guards.module';
import { AuthorizationModule } from '../authorization/authorization.module';
import { Reserve } from '../reserves/entities/reserve.entity';
import { Salon } from '../salons/entities/salon.entity';
import { NotificationScenarioSetting } from './entities/notification-scenario-setting.entity';
import { Notification } from './entities/notification.entity';
import { NotificationDispatchService } from './notification-dispatch.service';
import { NotificationSettingsController } from './notification-settings.controller';
import { NotificationSettingsService } from './notification-settings.service';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { SalonNotificationsService } from './salon-notifications.service';
@Module({
imports: [
TypeOrmModule.forFeature([
Notification,
NotificationScenarioSetting,
Salon,
Reserve,
]),
AuthorizationModule,
AuthGuardsModule,
],
controllers: [NotificationsController, NotificationSettingsController],
providers: [
NotificationsService,
NotificationSettingsService,
NotificationDispatchService,
SalonNotificationsService,
],
exports: [NotificationDispatchService, SalonNotificationsService],
})
export class NotificationsModule {}
@@ -0,0 +1,84 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { UserRole } from '../users/enums/user-role.enum';
import { FindNotificationsQueryDto } from './dto/find-notifications-query.dto';
import { Notification } from './entities/notification.entity';
@Injectable()
export class NotificationsService {
constructor(
@InjectRepository(Notification)
private readonly notificationsRepository: Repository<Notification>,
) {}
async findForUser(
user: AuthUser,
query: FindNotificationsQueryDto,
): Promise<Notification[]> {
this.ensureSalonUser(user);
const qb = this.notificationsRepository
.createQueryBuilder('notification')
.where('notification.userId = :userId', { userId: user.id })
.orderBy('notification.createdAt', 'DESC')
.take(query.limit ?? 50)
.skip(query.offset ?? 0);
if (query.unreadOnly) {
qb.andWhere('notification.isRead = false');
}
return qb.getMany();
}
async getUnreadCount(user: AuthUser): Promise<{ count: number }> {
this.ensureSalonUser(user);
const count = await this.notificationsRepository.count({
where: { userId: user.id, isRead: false },
});
return { count };
}
async markAsRead(id: string, user: AuthUser): Promise<Notification> {
this.ensureSalonUser(user);
const notification = await this.notificationsRepository.findOne({
where: { id, userId: user.id },
});
if (!notification) {
throw new NotFoundException(`Notification #${id} not found`);
}
if (!notification.isRead) {
notification.isRead = true;
await this.notificationsRepository.save(notification);
}
return notification;
}
async markAllAsRead(user: AuthUser): Promise<{ updated: number }> {
this.ensureSalonUser(user);
const result = await this.notificationsRepository.update(
{ userId: user.id, isRead: false },
{ isRead: true },
);
return { updated: result.affected ?? 0 };
}
private ensureSalonUser(user: AuthUser): void {
if (user.role !== UserRole.SALON) {
throw new ForbiddenException();
}
}
}
@@ -0,0 +1,195 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CampaignType } from '../campaigns/enums/campaign-type.enum';
import { getCampaignTypeDefinition } from '../campaigns/constants/campaign-types.constants';
import { Reserve } from '../reserves/entities/reserve.entity';
import { Salon } from '../salons/entities/salon.entity';
import { buildReserveSmsVariables } from '../sms-settings/utils/build-reserve-sms-variables.util';
import { NotificationScenarioKey } from './enums/notification-scenario-key.enum';
import { NotificationDispatchService } from './notification-dispatch.service';
@Injectable()
export class SalonNotificationsService {
private readonly logger = new Logger(SalonNotificationsService.name);
constructor(
private readonly notificationDispatchService: NotificationDispatchService,
@InjectRepository(Salon)
private readonly salonsRepository: Repository<Salon>,
@InjectRepository(Reserve)
private readonly reservesRepository: Repository<Reserve>,
) {}
async notifyReserveCreated(reserveId: string): Promise<void> {
await this.notifyForReserve(
reserveId,
NotificationScenarioKey.RESERVE_CREATED,
`/work-schedule`,
);
}
async notifyReserveConfirmed(reserveId: string): Promise<void> {
await this.notifyForReserve(
reserveId,
NotificationScenarioKey.RESERVE_CONFIRMED,
`/work-schedule`,
);
}
async notifyReserveCancelled(reserveId: string): Promise<void> {
await this.notifyForReserve(
reserveId,
NotificationScenarioKey.RESERVE_CANCELLED,
`/work-schedule`,
);
}
async notifyReserveRemindersSent(params: {
salonId: string;
sentCount: number;
appointmentDate: string;
}): Promise<void> {
if (params.sentCount <= 0) {
return;
}
const salon = await this.salonsRepository.findOne({
where: { id: params.salonId },
});
if (!salon) {
return;
}
const appointmentDate = this.formatDate(params.appointmentDate);
await this.safeCreate({
salonId: salon.id,
userId: salon.ownerId,
key: NotificationScenarioKey.RESERVE_REMINDER_SENT,
variables: {
salonName: salon.name,
sentCount: params.sentCount.toLocaleString('fa-IR'),
appointmentDate,
},
link: '/work-schedule',
relatedEntityType: 'reserve_reminder',
relatedEntityId: null,
});
}
async notifyCampaignSent(params: {
salon: Salon;
type: CampaignType;
campaignRunId: string;
sentCount: number;
failedCount: number;
targetCount: number;
estimatedRevenue: number;
}): Promise<void> {
const definition = getCampaignTypeDefinition(params.type);
const key =
params.sentCount === 0
? NotificationScenarioKey.CAMPAIGN_FAILED
: NotificationScenarioKey.CAMPAIGN_SENT;
await this.safeCreate({
salonId: params.salon.id,
userId: params.salon.ownerId,
key,
variables: {
salonName: params.salon.name,
campaignLabel: definition.label,
campaignType: params.type,
sentCount: params.sentCount.toLocaleString('fa-IR'),
failedCount: params.failedCount.toLocaleString('fa-IR'),
targetCount: params.targetCount.toLocaleString('fa-IR'),
estimatedRevenue: params.estimatedRevenue.toLocaleString('fa-IR'),
},
link: `/campaigns/results/${params.campaignRunId}`,
relatedEntityType: 'campaign_run',
relatedEntityId: params.campaignRunId,
});
}
async notifyCampaignFailed(params: {
salon: Salon;
type: CampaignType;
targetCount: number;
}): Promise<void> {
const definition = getCampaignTypeDefinition(params.type);
await this.safeCreate({
salonId: params.salon.id,
userId: params.salon.ownerId,
key: NotificationScenarioKey.CAMPAIGN_FAILED,
variables: {
salonName: params.salon.name,
campaignLabel: definition.label,
campaignType: params.type,
targetCount: params.targetCount.toLocaleString('fa-IR'),
},
link: '/campaigns',
relatedEntityType: 'campaign',
relatedEntityId: null,
});
}
private async notifyForReserve(
reserveId: string,
key: NotificationScenarioKey,
link: string,
): Promise<void> {
const reserve = await this.reservesRepository.findOne({
where: { id: reserveId },
relations: {
salon: true,
customer: { user: true },
stylist: { user: true },
},
});
if (!reserve?.salon) {
return;
}
const variables = buildReserveSmsVariables(reserve);
await this.safeCreate({
salonId: reserve.salonId,
userId: reserve.salon.ownerId,
key,
variables,
link,
relatedEntityType: 'reserve',
relatedEntityId: reserve.id,
});
}
private async safeCreate(params: {
salonId: string;
userId: string;
key: NotificationScenarioKey;
variables: Record<string, string>;
link?: string | null;
relatedEntityType?: string | null;
relatedEntityId?: string | null;
}): Promise<void> {
try {
await this.notificationDispatchService.createForSalon(params);
} catch (error) {
this.logger.error(
`Failed to create notification ${params.key} for salon ${params.salonId}`,
error instanceof Error ? error.stack : undefined,
);
}
}
private formatDate(date: string): string {
const parsed = new Date(`${date}T00:00:00`);
if (Number.isNaN(parsed.getTime())) {
return date;
}
return parsed.toLocaleDateString('fa-IR');
}
}
+2
View File
@@ -7,6 +7,7 @@ import { SalonsModule } from '../salons/salons.module';
import { ServiceSkill } from '../services/entities/service-skill.entity'; import { ServiceSkill } from '../services/entities/service-skill.entity';
import { Service } from '../services/entities/service.entity'; import { Service } from '../services/entities/service.entity';
import { ServicesModule } from '../services/services.module'; import { ServicesModule } from '../services/services.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SmsSettingsModule } from '../sms-settings/sms-settings.module'; import { SmsSettingsModule } from '../sms-settings/sms-settings.module';
import { StylistsModule } from '../stylists/stylists.module'; import { StylistsModule } from '../stylists/stylists.module';
import { ReserveItem } from './entities/reserve-item.entity'; import { ReserveItem } from './entities/reserve-item.entity';
@@ -31,6 +32,7 @@ import { ReservesService } from './reserves.service';
AuthorizationModule, AuthorizationModule,
AuthGuardsModule, AuthGuardsModule,
SmsSettingsModule, SmsSettingsModule,
NotificationsModule,
], ],
controllers: [ReservesController], controllers: [ReservesController],
providers: [ReservesService], providers: [ReservesService],
+5
View File
@@ -22,6 +22,7 @@ import { UpdateReserveDto } from './dto/update-reserve.dto';
import { ReserveItem } from './entities/reserve-item.entity'; import { ReserveItem } from './entities/reserve-item.entity';
import { ReserveStatusHistory } from './entities/reserve-status-history.entity'; import { ReserveStatusHistory } from './entities/reserve-status-history.entity';
import { Reserve } from './entities/reserve.entity'; import { Reserve } from './entities/reserve.entity';
import { SalonNotificationsService } from '../notifications/salon-notifications.service';
import { ReserveSmsService } from '../sms-settings/reserve-sms.service'; import { ReserveSmsService } from '../sms-settings/reserve-sms.service';
import { ReserveStatus } from './enums/reserve-status.enum'; import { ReserveStatus } from './enums/reserve-status.enum';
@@ -44,6 +45,7 @@ export class ReservesService {
private readonly authorizationService: AuthorizationService, private readonly authorizationService: AuthorizationService,
private readonly reservePolicy: ReservePolicy, private readonly reservePolicy: ReservePolicy,
private readonly reserveSmsService: ReserveSmsService, private readonly reserveSmsService: ReserveSmsService,
private readonly salonNotificationsService: SalonNotificationsService,
private readonly servicesService: ServicesService, private readonly servicesService: ServicesService,
) {} ) {}
@@ -103,6 +105,7 @@ export class ReservesService {
await this.customersService.linkToSalon(saved.salonId, saved.customerId); await this.customersService.linkToSalon(saved.salonId, saved.customerId);
await this.logStatusChange(saved.id, null, saved.status, user.id); await this.logStatusChange(saved.id, null, saved.status, user.id);
await this.reserveSmsService.sendReserveCreated(saved.id); await this.reserveSmsService.sendReserveCreated(saved.id);
await this.salonNotificationsService.notifyReserveCreated(saved.id);
return this.findOne(saved.id, user); return this.findOne(saved.id, user);
} }
@@ -262,6 +265,7 @@ export class ReservesService {
previousStatus !== ReserveStatus.CANCELLED previousStatus !== ReserveStatus.CANCELLED
) { ) {
await this.reserveSmsService.sendReserveCancelled(reserve.id); await this.reserveSmsService.sendReserveCancelled(reserve.id);
await this.salonNotificationsService.notifyReserveCancelled(reserve.id);
} }
return this.findOne(id, user); return this.findOne(id, user);
@@ -314,6 +318,7 @@ export class ReservesService {
reserve.status = ReserveStatus.CONFIRMED; reserve.status = ReserveStatus.CONFIRMED;
await this.reservesRepository.save(reserve); await this.reservesRepository.save(reserve);
await this.reserveSmsService.sendReserveConfirmed(reserve.id); await this.reserveSmsService.sendReserveConfirmed(reserve.id);
await this.salonNotificationsService.notifyReserveConfirmed(reserve.id);
} }
private async logStatusChange( private async logStatusChange(
+16
View File
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { SalonNotificationsService } from '../notifications/salon-notifications.service';
import { Reserve } from '../reserves/entities/reserve.entity'; import { Reserve } from '../reserves/entities/reserve.entity';
import { ReserveStatus } from '../reserves/enums/reserve-status.enum'; import { ReserveStatus } from '../reserves/enums/reserve-status.enum';
import { SmsScenarioKey } from './enums/sms-scenario-key.enum'; import { SmsScenarioKey } from './enums/sms-scenario-key.enum';
@@ -14,6 +15,7 @@ export class ReserveSmsService {
constructor( constructor(
private readonly smsDispatchService: SmsDispatchService, private readonly smsDispatchService: SmsDispatchService,
private readonly salonNotificationsService: SalonNotificationsService,
@InjectRepository(Reserve) @InjectRepository(Reserve)
private readonly reservesRepository: Repository<Reserve>, private readonly reservesRepository: Repository<Reserve>,
) {} ) {}
@@ -57,7 +59,9 @@ export class ReserveSmsService {
}, },
}); });
const sentBySalon = new Map<string, number>();
let sentCount = 0; let sentCount = 0;
for (const reserve of reserves) { for (const reserve of reserves) {
const sent = await this.sendForLoadedReserve( const sent = await this.sendForLoadedReserve(
reserve, reserve,
@@ -65,9 +69,21 @@ export class ReserveSmsService {
); );
if (sent) { if (sent) {
sentCount += 1; sentCount += 1;
sentBySalon.set(
reserve.salonId,
(sentBySalon.get(reserve.salonId) ?? 0) + 1,
);
} }
} }
for (const [salonId, salonSentCount] of sentBySalon) {
await this.salonNotificationsService.notifyReserveRemindersSent({
salonId,
sentCount: salonSentCount,
appointmentDate: today,
});
}
this.logger.log(`Sent ${sentCount} reserve reminder SMS for ${today}`); this.logger.log(`Sent ${sentCount} reserve reminder SMS for ${today}`);
return sentCount; return sentCount;
} }
+2
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthGuardsModule } from '../auth/auth-guards.module'; import { AuthGuardsModule } from '../auth/auth-guards.module';
import { AuthorizationModule } from '../authorization/authorization.module'; import { AuthorizationModule } from '../authorization/authorization.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { Reserve } from '../reserves/entities/reserve.entity'; import { Reserve } from '../reserves/entities/reserve.entity';
import { SmsModule } from '../sms/sms.module'; import { SmsModule } from '../sms/sms.module';
import { SmsTemplatesModule } from '../sms-templates/sms-templates.module'; import { SmsTemplatesModule } from '../sms-templates/sms-templates.module';
@@ -18,6 +19,7 @@ import { SmsSettingsService } from './sms-settings.service';
SmsTemplatesModule, SmsTemplatesModule,
AuthorizationModule, AuthorizationModule,
AuthGuardsModule, AuthGuardsModule,
NotificationsModule,
], ],
controllers: [SmsSettingsController], controllers: [SmsSettingsController],
providers: [SmsSettingsService, SmsDispatchService, ReserveSmsService], providers: [SmsSettingsService, SmsDispatchService, ReserveSmsService],