From c485ee326cb61ece2f529b33cc50154e3af3836a Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Thu, 9 Jul 2026 20:58:19 +0330 Subject: [PATCH] notification --- src/app.module.ts | 2 + src/campaigns/campaigns.module.ts | 2 + src/campaigns/campaigns.service.ts | 22 +- .../1721500000000-AddNotifications.ts | 161 ++++++++++ .../notification-scenarios.constants.ts | 291 ++++++++++++++++++ .../dto/find-notifications-query.dto.ts | 26 ++ ...pdate-notification-scenario-setting.dto.ts | 39 +++ .../notification-scenario-setting.entity.ts | 38 +++ .../entities/notification.entity.ts | 67 ++++ .../enums/notification-scenario-key.enum.ts | 8 + .../notification-dispatch.service.ts | 135 ++++++++ .../notification-settings.controller.ts | 79 +++++ .../notification-settings.service.ts | 130 ++++++++ src/notifications/notifications.controller.ts | 63 ++++ src/notifications/notifications.module.ts | 36 +++ src/notifications/notifications.service.ts | 84 +++++ .../salon-notifications.service.ts | 195 ++++++++++++ src/reserves/reserves.module.ts | 2 + src/reserves/reserves.service.ts | 5 + src/sms-settings/reserve-sms.service.ts | 16 + src/sms-settings/sms-settings.module.ts | 2 + 21 files changed, 1402 insertions(+), 1 deletion(-) create mode 100644 src/database/migrations/1721500000000-AddNotifications.ts create mode 100644 src/notifications/constants/notification-scenarios.constants.ts create mode 100644 src/notifications/dto/find-notifications-query.dto.ts create mode 100644 src/notifications/dto/update-notification-scenario-setting.dto.ts create mode 100644 src/notifications/entities/notification-scenario-setting.entity.ts create mode 100644 src/notifications/entities/notification.entity.ts create mode 100644 src/notifications/enums/notification-scenario-key.enum.ts create mode 100644 src/notifications/notification-dispatch.service.ts create mode 100644 src/notifications/notification-settings.controller.ts create mode 100644 src/notifications/notification-settings.service.ts create mode 100644 src/notifications/notifications.controller.ts create mode 100644 src/notifications/notifications.module.ts create mode 100644 src/notifications/notifications.service.ts create mode 100644 src/notifications/salon-notifications.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index b4d549c..abf735b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,7 @@ import { PaymentsModule } from './payments/payments.module'; import { ProvincesModule } from './provinces/provinces.module'; import { UploaderModule } from './uploader/uploader.module'; import { DashboardModule } from './dashboard/dashboard.module'; +import { NotificationsModule } from './notifications/notifications.module'; @Module({ imports: [ @@ -49,6 +50,7 @@ import { DashboardModule } from './dashboard/dashboard.module'; ProvincesModule, UploaderModule, DashboardModule, + NotificationsModule, ], controllers: [], providers: [], diff --git a/src/campaigns/campaigns.module.ts b/src/campaigns/campaigns.module.ts index 8e7c9e8..078dbde 100644 --- a/src/campaigns/campaigns.module.ts +++ b/src/campaigns/campaigns.module.ts @@ -4,6 +4,7 @@ import { AuthGuardsModule } from '../auth/auth-guards.module'; import { AuthorizationModule } from '../authorization/authorization.module'; import { SalonCustomer } from '../customers/entities/salon-customer.entity'; import { Reserve } from '../reserves/entities/reserve.entity'; +import { NotificationsModule } from '../notifications/notifications.module'; import { Salon } from '../salons/entities/salon.entity'; import { SentMessage } from '../sent-messages/entities/sent-message.entity'; import { SmsModule } from '../sms/sms.module'; @@ -28,6 +29,7 @@ import { CampaignRun } from './entities/campaign-run.entity'; ]), SmsModule, SmsSettingsModule, + NotificationsModule, SubscriptionsModule, AuthorizationModule, AuthGuardsModule, diff --git a/src/campaigns/campaigns.service.ts b/src/campaigns/campaigns.service.ts index e2a832b..b6b477e 100644 --- a/src/campaigns/campaigns.service.ts +++ b/src/campaigns/campaigns.service.ts @@ -8,6 +8,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { AuthUser } from '../auth/interfaces/auth-user.interface'; import { AuthorizationService } from '../authorization/authorization.service'; +import { SalonNotificationsService } from '../notifications/salon-notifications.service'; import { Salon } from '../salons/entities/salon.entity'; import { SentMessage } from '../sent-messages/entities/sent-message.entity'; import { SentMessageStatus } from '../sent-messages/enums/sent-message-status.enum'; @@ -56,6 +57,7 @@ export class CampaignsService { private readonly smsDispatchService: SmsDispatchService, private readonly smsService: SmsService, private readonly salonSubscriptionsService: SalonSubscriptionsService, + private readonly salonNotificationsService: SalonNotificationsService, private readonly authorizationService: AuthorizationService, ) {} @@ -198,7 +200,7 @@ export class CampaignsService { targetCount: number; estimatedRevenue: number; }): Promise { - return this.campaignRunsRepository.save( + const run = await this.campaignRunsRepository.save( this.campaignRunsRepository.create({ salonId: params.salon.id, type: params.type, @@ -211,6 +213,14 @@ export class CampaignsService { triggeredByUserId: null, }), ); + + await this.salonNotificationsService.notifyCampaignFailed({ + salon: params.salon, + type: params.type, + targetCount: params.targetCount, + }); + + return run; } sumEstimatedRevenue(targets: CampaignTargetCustomer[]): number { @@ -352,6 +362,16 @@ export class CampaignsService { : CampaignRunStatus.PARTIAL; 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) { diff --git a/src/database/migrations/1721500000000-AddNotifications.ts b/src/database/migrations/1721500000000-AddNotifications.ts new file mode 100644 index 0000000..6b2aace --- /dev/null +++ b/src/database/migrations/1721500000000-AddNotifications.ts @@ -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 { + 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 { + await queryRunner.dropTable('notifications', true); + await queryRunner.dropTable('notification_scenario_settings', true); + await queryRunner.query('DROP TYPE "notification_scenario_key_enum"'); + } +} diff --git a/src/notifications/constants/notification-scenarios.constants.ts b/src/notifications/constants/notification-scenarios.constants.ts new file mode 100644 index 0000000..a52882f --- /dev/null +++ b/src/notifications/constants/notification-scenarios.constants.ts @@ -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 { + const scenario = NOTIFICATION_SCENARIO_MAP.get(key); + if (!scenario) { + return {}; + } + + return Object.fromEntries( + scenario.variables.map((variable) => [variable.key, variable.sampleValue]), + ); +} diff --git a/src/notifications/dto/find-notifications-query.dto.ts b/src/notifications/dto/find-notifications-query.dto.ts new file mode 100644 index 0000000..2aec0c7 --- /dev/null +++ b/src/notifications/dto/find-notifications-query.dto.ts @@ -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; +} diff --git a/src/notifications/dto/update-notification-scenario-setting.dto.ts b/src/notifications/dto/update-notification-scenario-setting.dto.ts new file mode 100644 index 0000000..f02ae35 --- /dev/null +++ b/src/notifications/dto/update-notification-scenario-setting.dto.ts @@ -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; +} diff --git a/src/notifications/entities/notification-scenario-setting.entity.ts b/src/notifications/entities/notification-scenario-setting.entity.ts new file mode 100644 index 0000000..8c7c6c4 --- /dev/null +++ b/src/notifications/entities/notification-scenario-setting.entity.ts @@ -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; +} diff --git a/src/notifications/entities/notification.entity.ts b/src/notifications/entities/notification.entity.ts new file mode 100644 index 0000000..f8b5801 --- /dev/null +++ b/src/notifications/entities/notification.entity.ts @@ -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; +} diff --git a/src/notifications/enums/notification-scenario-key.enum.ts b/src/notifications/enums/notification-scenario-key.enum.ts new file mode 100644 index 0000000..b7f8a13 --- /dev/null +++ b/src/notifications/enums/notification-scenario-key.enum.ts @@ -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', +} diff --git a/src/notifications/notification-dispatch.service.ts b/src/notifications/notification-dispatch.service.ts new file mode 100644 index 0000000..0781519 --- /dev/null +++ b/src/notifications/notification-dispatch.service.ts @@ -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; + link?: string | null; + relatedEntityType?: string | null; + relatedEntityId?: string | null; +} + +@Injectable() +export class NotificationDispatchService { + constructor( + @InjectRepository(NotificationScenarioSetting) + private readonly settingsRepository: Repository, + @InjectRepository(Notification) + private readonly notificationsRepository: Repository, + ) {} + + getScenarioDefinitions() { + return NOTIFICATION_SCENARIOS; + } + + async findAllSettings(): Promise { + return this.settingsRepository.find({ + order: { key: 'ASC' }, + }); + } + + async findSettingByKey( + key: NotificationScenarioKey, + ): Promise { + 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, + ): 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 { + 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, + }), + ); + } +} diff --git a/src/notifications/notification-settings.controller.ts b/src/notifications/notification-settings.controller.ts new file mode 100644 index 0000000..13edcbb --- /dev/null +++ b/src/notifications/notification-settings.controller.ts @@ -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); + } +} diff --git a/src/notifications/notification-settings.service.ts b/src/notifications/notification-settings.service.ts new file mode 100644 index 0000000..f3572cf --- /dev/null +++ b/src/notifications/notification-settings.service.ts @@ -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, + private readonly notificationDispatchService: NotificationDispatchService, + private readonly servicePolicy: ServicePolicy, + ) {} + + getScenarioDefinitions(): NotificationScenarioDefinition[] { + return this.notificationDispatchService.getScenarioDefinitions(); + } + + async findAll(user: AuthUser): Promise { + 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 { + 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 { + 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(); + } + } +} diff --git a/src/notifications/notifications.controller.ts b/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..d72f0ba --- /dev/null +++ b/src/notifications/notifications.controller.ts @@ -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); + } +} diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts new file mode 100644 index 0000000..a4554b2 --- /dev/null +++ b/src/notifications/notifications.module.ts @@ -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 {} diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts new file mode 100644 index 0000000..516fadb --- /dev/null +++ b/src/notifications/notifications.service.ts @@ -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, + ) {} + + async findForUser( + user: AuthUser, + query: FindNotificationsQueryDto, + ): Promise { + 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 { + 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(); + } + } +} diff --git a/src/notifications/salon-notifications.service.ts b/src/notifications/salon-notifications.service.ts new file mode 100644 index 0000000..3bcb55e --- /dev/null +++ b/src/notifications/salon-notifications.service.ts @@ -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, + @InjectRepository(Reserve) + private readonly reservesRepository: Repository, + ) {} + + async notifyReserveCreated(reserveId: string): Promise { + await this.notifyForReserve( + reserveId, + NotificationScenarioKey.RESERVE_CREATED, + `/work-schedule`, + ); + } + + async notifyReserveConfirmed(reserveId: string): Promise { + await this.notifyForReserve( + reserveId, + NotificationScenarioKey.RESERVE_CONFIRMED, + `/work-schedule`, + ); + } + + async notifyReserveCancelled(reserveId: string): Promise { + await this.notifyForReserve( + reserveId, + NotificationScenarioKey.RESERVE_CANCELLED, + `/work-schedule`, + ); + } + + async notifyReserveRemindersSent(params: { + salonId: string; + sentCount: number; + appointmentDate: string; + }): Promise { + 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 { + 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 { + 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 { + 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; + link?: string | null; + relatedEntityType?: string | null; + relatedEntityId?: string | null; + }): Promise { + 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'); + } +} diff --git a/src/reserves/reserves.module.ts b/src/reserves/reserves.module.ts index 47dec22..aaa724b 100644 --- a/src/reserves/reserves.module.ts +++ b/src/reserves/reserves.module.ts @@ -7,6 +7,7 @@ import { SalonsModule } from '../salons/salons.module'; import { ServiceSkill } from '../services/entities/service-skill.entity'; import { Service } from '../services/entities/service.entity'; import { ServicesModule } from '../services/services.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { SmsSettingsModule } from '../sms-settings/sms-settings.module'; import { StylistsModule } from '../stylists/stylists.module'; import { ReserveItem } from './entities/reserve-item.entity'; @@ -31,6 +32,7 @@ import { ReservesService } from './reserves.service'; AuthorizationModule, AuthGuardsModule, SmsSettingsModule, + NotificationsModule, ], controllers: [ReservesController], providers: [ReservesService], diff --git a/src/reserves/reserves.service.ts b/src/reserves/reserves.service.ts index 515aad1..4176176 100644 --- a/src/reserves/reserves.service.ts +++ b/src/reserves/reserves.service.ts @@ -22,6 +22,7 @@ import { UpdateReserveDto } from './dto/update-reserve.dto'; import { ReserveItem } from './entities/reserve-item.entity'; import { ReserveStatusHistory } from './entities/reserve-status-history.entity'; import { Reserve } from './entities/reserve.entity'; +import { SalonNotificationsService } from '../notifications/salon-notifications.service'; import { ReserveSmsService } from '../sms-settings/reserve-sms.service'; import { ReserveStatus } from './enums/reserve-status.enum'; @@ -44,6 +45,7 @@ export class ReservesService { private readonly authorizationService: AuthorizationService, private readonly reservePolicy: ReservePolicy, private readonly reserveSmsService: ReserveSmsService, + private readonly salonNotificationsService: SalonNotificationsService, private readonly servicesService: ServicesService, ) {} @@ -103,6 +105,7 @@ export class ReservesService { await this.customersService.linkToSalon(saved.salonId, saved.customerId); await this.logStatusChange(saved.id, null, saved.status, user.id); await this.reserveSmsService.sendReserveCreated(saved.id); + await this.salonNotificationsService.notifyReserveCreated(saved.id); return this.findOne(saved.id, user); } @@ -262,6 +265,7 @@ export class ReservesService { previousStatus !== ReserveStatus.CANCELLED ) { await this.reserveSmsService.sendReserveCancelled(reserve.id); + await this.salonNotificationsService.notifyReserveCancelled(reserve.id); } return this.findOne(id, user); @@ -314,6 +318,7 @@ export class ReservesService { reserve.status = ReserveStatus.CONFIRMED; await this.reservesRepository.save(reserve); await this.reserveSmsService.sendReserveConfirmed(reserve.id); + await this.salonNotificationsService.notifyReserveConfirmed(reserve.id); } private async logStatusChange( diff --git a/src/sms-settings/reserve-sms.service.ts b/src/sms-settings/reserve-sms.service.ts index edb228b..06690f7 100644 --- a/src/sms-settings/reserve-sms.service.ts +++ b/src/sms-settings/reserve-sms.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { SalonNotificationsService } from '../notifications/salon-notifications.service'; import { Reserve } from '../reserves/entities/reserve.entity'; import { ReserveStatus } from '../reserves/enums/reserve-status.enum'; import { SmsScenarioKey } from './enums/sms-scenario-key.enum'; @@ -14,6 +15,7 @@ export class ReserveSmsService { constructor( private readonly smsDispatchService: SmsDispatchService, + private readonly salonNotificationsService: SalonNotificationsService, @InjectRepository(Reserve) private readonly reservesRepository: Repository, ) {} @@ -57,7 +59,9 @@ export class ReserveSmsService { }, }); + const sentBySalon = new Map(); let sentCount = 0; + for (const reserve of reserves) { const sent = await this.sendForLoadedReserve( reserve, @@ -65,9 +69,21 @@ export class ReserveSmsService { ); if (sent) { 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}`); return sentCount; } diff --git a/src/sms-settings/sms-settings.module.ts b/src/sms-settings/sms-settings.module.ts index beb0b05..a48e3d7 100644 --- a/src/sms-settings/sms-settings.module.ts +++ b/src/sms-settings/sms-settings.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthGuardsModule } from '../auth/auth-guards.module'; import { AuthorizationModule } from '../authorization/authorization.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { Reserve } from '../reserves/entities/reserve.entity'; import { SmsModule } from '../sms/sms.module'; import { SmsTemplatesModule } from '../sms-templates/sms-templates.module'; @@ -18,6 +19,7 @@ import { SmsSettingsService } from './sms-settings.service'; SmsTemplatesModule, AuthorizationModule, AuthGuardsModule, + NotificationsModule, ], controllers: [SmsSettingsController], providers: [SmsSettingsService, SmsDispatchService, ReserveSmsService],