notification
This commit is contained in:
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CampaignRun> {
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Reserve>,
|
||||
) {}
|
||||
@@ -57,7 +59,9 @@ export class ReserveSmsService {
|
||||
},
|
||||
});
|
||||
|
||||
const sentBySalon = new Map<string, number>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user