From ffb95149cfe9794f83ff315f6d3136ae9c8995eb Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Thu, 9 Jul 2026 19:12:23 +0330 Subject: [PATCH] sms-setting and campain --- package-lock.json | 46 ++ package.json | 1 + src/app.module.ts | 6 + src/auth/auth.module.ts | 4 +- src/auth/auth.service.ts | 14 +- .../enums/permission-code.enum.ts | 2 + src/campaigns/campaign-automation.service.ts | 167 +++++++ src/campaigns/campaign-targeting.service.ts | 369 ++++++++++++++++ src/campaigns/campaigns.controller.ts | 120 +++++ src/campaigns/campaigns.module.ts | 43 ++ src/campaigns/campaigns.service.ts | 357 +++++++++++++++ .../constants/campaign-types.constants.ts | 100 +++++ .../dto/find-campaign-runs-query.dto.ts | 21 + src/campaigns/dto/send-campaign.dto.ts | 15 + src/campaigns/entities/campaign-run.entity.ts | 85 ++++ .../enums/campaign-run-status.enum.ts | 5 + src/campaigns/enums/campaign-trigger.enum.ts | 4 + src/campaigns/enums/campaign-type.enum.ts | 8 + .../1721200000000-AddSmsScenarioSettings.ts | 85 ++++ .../migrations/1721300000000-AddCampaigns.ts | 218 ++++++++++ src/reserves/reserves.module.ts | 2 + src/reserves/reserves.service.ts | 20 +- .../entities/sent-message.entity.ts | 9 + .../constants/sms-scenarios.constants.ts | 411 ++++++++++++++++++ .../dto/update-sms-scenario-setting.dto.ts | 41 ++ .../entities/sms-scenario-setting.entity.ts | 45 ++ .../enums/sms-scenario-key.enum.ts | 13 + src/sms-settings/reserve-sms.service.ts | 114 +++++ src/sms-settings/sms-dispatch.service.ts | 125 ++++++ src/sms-settings/sms-settings.controller.ts | 87 ++++ src/sms-settings/sms-settings.module.ts | 26 ++ src/sms-settings/sms-settings.service.ts | 144 ++++++ .../utils/build-reserve-sms-variables.util.ts | 44 ++ 33 files changed, 2745 insertions(+), 6 deletions(-) create mode 100644 src/campaigns/campaign-automation.service.ts create mode 100644 src/campaigns/campaign-targeting.service.ts create mode 100644 src/campaigns/campaigns.controller.ts create mode 100644 src/campaigns/campaigns.module.ts create mode 100644 src/campaigns/campaigns.service.ts create mode 100644 src/campaigns/constants/campaign-types.constants.ts create mode 100644 src/campaigns/dto/find-campaign-runs-query.dto.ts create mode 100644 src/campaigns/dto/send-campaign.dto.ts create mode 100644 src/campaigns/entities/campaign-run.entity.ts create mode 100644 src/campaigns/enums/campaign-run-status.enum.ts create mode 100644 src/campaigns/enums/campaign-trigger.enum.ts create mode 100644 src/campaigns/enums/campaign-type.enum.ts create mode 100644 src/database/migrations/1721200000000-AddSmsScenarioSettings.ts create mode 100644 src/database/migrations/1721300000000-AddCampaigns.ts create mode 100644 src/sms-settings/constants/sms-scenarios.constants.ts create mode 100644 src/sms-settings/dto/update-sms-scenario-setting.dto.ts create mode 100644 src/sms-settings/entities/sms-scenario-setting.entity.ts create mode 100644 src/sms-settings/enums/sms-scenario-key.enum.ts create mode 100644 src/sms-settings/reserve-sms.service.ts create mode 100644 src/sms-settings/sms-dispatch.service.ts create mode 100644 src/sms-settings/sms-settings.controller.ts create mode 100644 src/sms-settings/sms-settings.module.ts create mode 100644 src/sms-settings/sms-settings.service.ts create mode 100644 src/sms-settings/utils/build-reserve-sms-variables.util.ts diff --git a/package-lock.json b/package-lock.json index 298c240..22f0fe0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^11.4.5", "@nestjs/typeorm": "^11.0.2", "axios": "^1.18.1", @@ -2822,6 +2823,19 @@ "node": ">= 0.6" } }, + "node_modules/@nestjs/schedule": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz", + "integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==", + "license": "MIT", + "dependencies": { + "cron": "4.4.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", @@ -3429,6 +3443,12 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==", + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -5481,6 +5501,23 @@ "devOptional": true, "license": "MIT" }, + "node_modules/cron": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", + "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/intcreator" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -8244,6 +8281,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", diff --git a/package.json b/package.json index dd1329e..d761585 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^11.4.5", "@nestjs/typeorm": "^11.0.2", "axios": "^1.18.1", diff --git a/src/app.module.ts b/src/app.module.ts index 8839179..b4d549c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthModule } from './auth/auth.module'; +import { CampaignsModule } from './campaigns/campaigns.module'; import { DatabaseModule } from './database/database.module'; import { CustomersModule } from './customers/customers.module'; import configuration from './config/configuration'; @@ -10,6 +12,7 @@ import { ReservesModule } from './reserves/reserves.module'; import { SalonsModule } from './salons/salons.module'; import { ServicesModule } from './services/services.module'; import { SentMessagesModule } from './sent-messages/sent-messages.module'; +import { SmsSettingsModule } from './sms-settings/sms-settings.module'; import { SmsTemplatesModule } from './sms-templates/sms-templates.module'; import { SuggestionsModule } from './suggestions/suggestions.module'; import { StylistsModule } from './stylists/stylists.module'; @@ -26,6 +29,7 @@ import { DashboardModule } from './dashboard/dashboard.module'; isGlobal: true, load: [configuration], }), + ScheduleModule.forRoot(), TypeOrmModule.forRoot(getTypeOrmOptions()), DatabaseModule, AuthModule, @@ -37,8 +41,10 @@ import { DashboardModule } from './dashboard/dashboard.module'; ReservesModule, SuggestionsModule, SmsTemplatesModule, + SmsSettingsModule, SentMessagesModule, SubscriptionsModule, + CampaignsModule, PaymentsModule, ProvincesModule, UploaderModule, diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 18e1db7..51371d7 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -8,7 +8,7 @@ import { AuthorizationModule } from '../authorization/authorization.module'; import { Customer } from '../customers/entities/customer.entity'; import { Salon } from '../salons/entities/salon.entity'; import { ProvincesModule } from '../provinces/provinces.module'; -import { SmsModule } from '../sms/sms.module'; +import { SmsSettingsModule } from '../sms-settings/sms-settings.module'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; import { UsersModule } from '../users/users.module'; import { AuthController } from './auth.controller'; @@ -34,7 +34,7 @@ import { JwtStrategy } from './strategies/jwt.strategy'; }), }), UsersModule, - SmsModule, + SmsSettingsModule, SubscriptionsModule, AuthorizationModule, ProvincesModule, diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 88637d9..5c27d33 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -15,7 +15,8 @@ import { Repository, IsNull } from 'typeorm'; import { Customer } from '../customers/entities/customer.entity'; import { Salon } from '../salons/entities/salon.entity'; import { ProvincesService } from '../provinces/provinces.service'; -import { SmsService } from '../sms/sms.service'; +import { SmsDispatchService } from '../sms-settings/sms-dispatch.service'; +import { SmsScenarioKey } from '../sms-settings/enums/sms-scenario-key.enum'; import { SalonSubscription } from '../subscriptions/entities/salon-subscription.entity'; import { SalonSubscriptionsService } from '../subscriptions/salon-subscriptions.service'; import { UserRole } from '../users/enums/user-role.enum'; @@ -73,7 +74,7 @@ export class AuthService { private readonly provincesService: ProvincesService, private readonly usersService: UsersService, private readonly salonSubscriptionsService: SalonSubscriptionsService, - private readonly smsService: SmsService, + private readonly smsDispatchService: SmsDispatchService, private readonly jwtService: JwtService, private readonly configService: ConfigService, ) { @@ -193,7 +194,14 @@ export class AuthService { expiresAt, }); - const sent = await this.smsService.sendSms(mobile, `کد ورود: ${code}`); + const sent = await this.smsDispatchService.sendScenarioSms( + SmsScenarioKey.OTP_LOGIN, + mobile, + { + code, + expiresInMinutes: String(expiresInMinutes), + }, + ); if (!sent && !this.shouldExposeOtpInResponse()) { throw new BadRequestException('Failed to send OTP'); diff --git a/src/authorization/enums/permission-code.enum.ts b/src/authorization/enums/permission-code.enum.ts index ecc3258..3b2af75 100644 --- a/src/authorization/enums/permission-code.enum.ts +++ b/src/authorization/enums/permission-code.enum.ts @@ -23,4 +23,6 @@ export enum PermissionCode { RESERVES_WRITE = 'reserves:write', SUBSCRIPTIONS_READ = 'subscriptions:read', SUBSCRIPTIONS_WRITE = 'subscriptions:write', + CAMPAIGNS_READ = 'campaigns:read', + CAMPAIGNS_WRITE = 'campaigns:write', } diff --git a/src/campaigns/campaign-automation.service.ts b/src/campaigns/campaign-automation.service.ts new file mode 100644 index 0000000..042fbe3 --- /dev/null +++ b/src/campaigns/campaign-automation.service.ts @@ -0,0 +1,167 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SalonCustomer } from '../customers/entities/salon-customer.entity'; +import { Salon } from '../salons/entities/salon.entity'; +import { SalonSubscriptionsService } from '../subscriptions/salon-subscriptions.service'; +import { CampaignTargetingService } from './campaign-targeting.service'; +import { CampaignsService } from './campaigns.service'; +import { CampaignTrigger } from './enums/campaign-trigger.enum'; +import { CampaignType } from './enums/campaign-type.enum'; + +@Injectable() +export class CampaignAutomationService { + private readonly logger = new Logger(CampaignAutomationService.name); + + constructor( + @InjectRepository(SalonCustomer) + private readonly salonCustomersRepository: Repository, + @InjectRepository(Salon) + private readonly salonsRepository: Repository, + private readonly targetingService: CampaignTargetingService, + private readonly campaignsService: CampaignsService, + private readonly salonSubscriptionsService: SalonSubscriptionsService, + ) {} + + /** + * Sends the "returning customers" reminder 4 days before the renewal due + * date, and again on the due date itself, for every salon. + */ + @Cron(CronExpression.EVERY_DAY_AT_9AM, { + name: 'campaign-returning-customers', + }) + async runReturningCustomersCampaign(): Promise { + const salonIds = await this.getSalonIdsWithCustomers(); + let totalRunsSent = 0; + + for (const salonId of salonIds) { + const contexts = + await this.targetingService.getSalonCustomerContexts(salonId); + const targets = this.targetingService.buildReturningCustomersTargets( + contexts, + { exactOffsetsOnly: true }, + ); + + if (targets.length === 0) { + continue; + } + + const sent = await this.dispatchAutomaticCampaign( + salonId, + CampaignType.RETURNING_CUSTOMERS, + targets, + ); + if (sent) { + totalRunsSent += 1; + } + } + + this.logger.log( + `Returning-customers campaign processed for ${totalRunsSent} salons`, + ); + return totalRunsSent; + } + + /** + * Sends a birthday SMS to every customer on the day of their birthday, from + * every salon they are linked to (a customer registered at two salons will + * receive a birthday SMS from both). + */ + @Cron(CronExpression.EVERY_DAY_AT_9AM, { name: 'campaign-birthday' }) + async runBirthdayCampaign(): Promise { + const now = new Date(); + const salonIds = await this.getSalonIdsWithBirthdayToday(now); + let totalRunsSent = 0; + + for (const salonId of salonIds) { + const contexts = + await this.targetingService.getSalonCustomerContexts(salonId); + const targets = this.targetingService.buildBirthdayTargets( + contexts, + now, + { exactDayOnly: true }, + ); + + if (targets.length === 0) { + continue; + } + + const sent = await this.dispatchAutomaticCampaign( + salonId, + CampaignType.BIRTHDAY, + targets, + ); + if (sent) { + totalRunsSent += 1; + } + } + + this.logger.log(`Birthday campaign processed for ${totalRunsSent} salons`); + return totalRunsSent; + } + + private async dispatchAutomaticCampaign( + salonId: string, + type: CampaignType, + targets: Awaited>, + ): Promise { + const salon = await this.salonsRepository.findOne({ + where: { id: salonId }, + }); + if (!salon) { + return false; + } + + try { + await this.salonSubscriptionsService.consumeSms(salonId, targets.length); + } catch (error) { + this.logger.warn( + `Skipping ${type} campaign for salon ${salonId}: ${(error as Error).message}`, + ); + await this.campaignsService.recordFailedRun({ + salon, + type, + trigger: CampaignTrigger.AUTOMATIC, + targetCount: targets.length, + estimatedRevenue: this.campaignsService.sumEstimatedRevenue(targets), + }); + return false; + } + + await this.campaignsService.runCampaignForSalon({ + salon, + type, + trigger: CampaignTrigger.AUTOMATIC, + targets, + triggeredByUserId: null, + }); + + return true; + } + + private async getSalonIdsWithCustomers(): Promise { + const rows = await this.salonCustomersRepository + .createQueryBuilder('salonCustomer') + .select('DISTINCT salonCustomer.salonId', 'salonId') + .getRawMany<{ salonId: string }>(); + + return rows.map((row) => row.salonId); + } + + private async getSalonIdsWithBirthdayToday(now: Date): Promise { + const rows = await this.salonCustomersRepository + .createQueryBuilder('salonCustomer') + .innerJoin('salonCustomer.customer', 'customer') + .select('DISTINCT salonCustomer.salonId', 'salonId') + .where('EXTRACT(MONTH FROM customer.dateOfBirth) = :month', { + month: now.getMonth() + 1, + }) + .andWhere('EXTRACT(DAY FROM customer.dateOfBirth) = :day', { + day: now.getDate(), + }) + .getRawMany<{ salonId: string }>(); + + return rows.map((row) => row.salonId); + } +} diff --git a/src/campaigns/campaign-targeting.service.ts b/src/campaigns/campaign-targeting.service.ts new file mode 100644 index 0000000..e662f94 --- /dev/null +++ b/src/campaigns/campaign-targeting.service.ts @@ -0,0 +1,369 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { + DEFAULT_RENEWAL_DAYS, + daysBetween, + isVipCustomer, + parseDate, + resolveRenewalDays, + resolveSegment, +} from '../customers/helpers/customer-segmentation.helper'; +import { SalonCustomer } from '../customers/entities/salon-customer.entity'; +import { Reserve } from '../reserves/entities/reserve.entity'; +import { ReserveStatus } from '../reserves/enums/reserve-status.enum'; +import { ServiceSuggestion } from '../suggestions/entities/service-suggestion.entity'; +import { SuggestionType } from '../suggestions/enums/suggestion-type.enum'; +import { + BIRTHDAY_UPCOMING_WINDOW_DAYS, + INACTIVE_CUSTOMER_DAYS_THRESHOLD, + RENEWAL_REMINDER_LEAD_DAYS, +} from './constants/campaign-types.constants'; +import { CampaignType } from './enums/campaign-type.enum'; + +export interface SalonCustomerContext { + customerId: string; + firstName: string; + lastName: string; + mobile: string; + dateOfBirth: string; + totalPurchase: number; + visitCount: number; + lastVisitDate: string | null; + daysSinceVisit: number | null; + renewalDays: number; + daysUntilRenewal: number | null; + lastServiceTitle: string | null; + lastServiceSkillIds: string[]; + purchasedServiceSkillIds: Set; +} + +export interface CampaignTargetCustomer { + customerId: string; + firstName: string; + lastName: string; + mobile: string; + totalPurchase: number; + visitCount: number; + lastVisitDate: string | null; + daysSinceVisit: number | null; + estimatedRevenue: number; + meta: Record; +} + +function estimateVisitRevenue(context: { + totalPurchase: number; + visitCount: number; +}): number { + if (context.visitCount <= 0) { + return 0; + } + return Math.round(context.totalPurchase / context.visitCount); +} + +function daysUntilNextBirthday(dateOfBirth: string, now: Date): number { + const parts = dateOfBirth.split('-').map(Number); + const month = parts[1]; + const day = parts[2]; + const todayUtc = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); + + let next = Date.UTC(now.getFullYear(), month - 1, day); + if (next < todayUtc) { + next = Date.UTC(now.getFullYear() + 1, month - 1, day); + } + + return Math.round((next - todayUtc) / (1000 * 60 * 60 * 24)); +} + +@Injectable() +export class CampaignTargetingService { + constructor( + @InjectRepository(SalonCustomer) + private readonly salonCustomersRepository: Repository, + @InjectRepository(Reserve) + private readonly reservesRepository: Repository, + @InjectRepository(ServiceSuggestion) + private readonly suggestionsRepository: Repository, + ) {} + + async getSalonCustomerContexts( + salonId: string, + ): Promise { + const links = await this.salonCustomersRepository.find({ + where: { salonId }, + relations: { customer: { user: true } }, + }); + + if (links.length === 0) { + return []; + } + + const customerIds = links.map((link) => link.customerId); + const completedReserves = await this.reservesRepository.find({ + where: { + salonId, + customerId: In(customerIds), + status: ReserveStatus.COMPLETED, + }, + relations: { items: { serviceSkill: true, service: true } }, + order: { appointmentDate: 'ASC' }, + }); + + const reservesByCustomer = new Map(); + for (const reserve of completedReserves) { + const list = reservesByCustomer.get(reserve.customerId) ?? []; + list.push(reserve); + reservesByCustomer.set(reserve.customerId, list); + } + + const now = new Date(); + + return links.map((link) => { + const reserves = reservesByCustomer.get(link.customerId) ?? []; + const totalPurchase = reserves.reduce( + (sum, reserve) => sum + reserve.totalAmount, + 0, + ); + const lastReserve = reserves[reserves.length - 1]; + const lastVisitDate = lastReserve?.appointmentDate ?? null; + const daysSinceVisit = lastVisitDate + ? daysBetween(parseDate(lastVisitDate), now) + : null; + const renewalDays = lastReserve + ? resolveRenewalDays(lastReserve.items) + : DEFAULT_RENEWAL_DAYS; + const daysUntilRenewal = + daysSinceVisit !== null ? renewalDays - daysSinceVisit : null; + const lastServiceTitle = lastReserve?.items?.[0]?.service?.title ?? null; + const lastServiceSkillIds = lastReserve + ? [...new Set(lastReserve.items.map((item) => item.serviceSkillId))] + : []; + const purchasedServiceSkillIds = new Set( + reserves.flatMap((reserve) => + reserve.items.map((item) => item.serviceSkillId), + ), + ); + + return { + customerId: link.customerId, + firstName: link.customer.user.firstName, + lastName: link.customer.user.lastName, + mobile: link.customer.user.mobile, + dateOfBirth: link.customer.dateOfBirth, + totalPurchase, + visitCount: reserves.length, + lastVisitDate, + daysSinceVisit, + renewalDays, + daysUntilRenewal, + lastServiceTitle, + lastServiceSkillIds, + purchasedServiceSkillIds, + }; + }); + } + + async getTargetsForType( + type: CampaignType, + salonId: string, + ): Promise { + const contexts = await this.getSalonCustomerContexts(salonId); + return this.filterContextsForType(type, contexts); + } + + async filterContextsForType( + type: CampaignType, + contexts: SalonCustomerContext[], + ): Promise { + switch (type) { + case CampaignType.RETURNING_CUSTOMERS: + return this.buildReturningCustomersTargets(contexts, { + exactOffsetsOnly: false, + }); + case CampaignType.BIRTHDAY: + return this.buildBirthdayTargets(contexts); + case CampaignType.AT_RISK: + return this.buildAtRiskTargets(contexts); + case CampaignType.VIP: + return this.buildVipTargets(contexts); + case CampaignType.INACTIVE: + return this.buildInactiveTargets(contexts); + case CampaignType.COMPLEMENTARY_SERVICE: + return this.buildComplementaryServiceTargets(contexts); + default: + return []; + } + } + + buildReturningCustomersTargets( + contexts: SalonCustomerContext[], + options: { exactOffsetsOnly: boolean }, + ): CampaignTargetCustomer[] { + return contexts + .filter((context) => context.daysUntilRenewal !== null) + .filter((context) => { + const daysUntilRenewal = context.daysUntilRenewal as number; + if (options.exactOffsetsOnly) { + return ( + daysUntilRenewal === 0 || + daysUntilRenewal === RENEWAL_REMINDER_LEAD_DAYS + ); + } + return ( + daysUntilRenewal >= 0 && + daysUntilRenewal <= RENEWAL_REMINDER_LEAD_DAYS + ); + }) + .map((context) => + this.toTargetCustomer(context, { + daysUntilRenewal: String(context.daysUntilRenewal ?? ''), + lastServiceName: context.lastServiceTitle ?? 'خدمت قبلی', + }), + ); + } + + buildBirthdayTargets( + contexts: SalonCustomerContext[], + now: Date = new Date(), + options: { exactDayOnly?: boolean } = {}, + ): CampaignTargetCustomer[] { + return contexts + .map((context) => ({ + context, + daysUntilBirthday: daysUntilNextBirthday(context.dateOfBirth, now), + })) + .filter(({ daysUntilBirthday }) => + options.exactDayOnly + ? daysUntilBirthday === 0 + : daysUntilBirthday <= BIRTHDAY_UPCOMING_WINDOW_DAYS, + ) + .map(({ context, daysUntilBirthday }) => + this.toTargetCustomer(context, { + daysUntilBirthday: String(daysUntilBirthday), + }), + ); + } + + buildAtRiskTargets( + contexts: SalonCustomerContext[], + ): CampaignTargetCustomer[] { + return contexts + .filter( + (context) => + context.daysSinceVisit !== null && + resolveSegment(context.daysSinceVisit, context.renewalDays) === + 'at-risk', + ) + .map((context) => + this.toTargetCustomer(context, { + daysSinceVisit: String(context.daysSinceVisit ?? ''), + }), + ); + } + + buildVipTargets(contexts: SalonCustomerContext[]): CampaignTargetCustomer[] { + return contexts + .filter((context) => isVipCustomer(context.totalPurchase)) + .map((context) => + this.toTargetCustomer(context, { + totalPurchase: context.totalPurchase.toLocaleString('fa-IR'), + }), + ); + } + + buildInactiveTargets( + contexts: SalonCustomerContext[], + ): CampaignTargetCustomer[] { + return contexts + .filter( + (context) => + context.daysSinceVisit !== null && + context.daysSinceVisit > INACTIVE_CUSTOMER_DAYS_THRESHOLD, + ) + .map((context) => + this.toTargetCustomer(context, { + daysSinceVisit: String(context.daysSinceVisit ?? ''), + }), + ); + } + + async buildComplementaryServiceTargets( + contexts: SalonCustomerContext[], + ): Promise { + const candidateContexts = contexts.filter( + (context) => context.lastServiceSkillIds.length > 0, + ); + if (candidateContexts.length === 0) { + return []; + } + + const serviceSkillIds = [ + ...new Set( + candidateContexts.flatMap((context) => context.lastServiceSkillIds), + ), + ]; + + const suggestions = await this.suggestionsRepository.find({ + where: { + serviceSkillId: In(serviceSkillIds), + isActive: true, + type: SuggestionType.COMPLEMENTARY, + }, + relations: { suggestedServiceSkill: { service: true } }, + order: { displayPriority: 'ASC' }, + }); + + const suggestionsBySkill = new Map(); + for (const suggestion of suggestions) { + const list = suggestionsBySkill.get(suggestion.serviceSkillId) ?? []; + list.push(suggestion); + suggestionsBySkill.set(suggestion.serviceSkillId, list); + } + + const targets: CampaignTargetCustomer[] = []; + + for (const context of candidateContexts) { + const matchingSuggestion = context.lastServiceSkillIds + .flatMap((skillId) => suggestionsBySkill.get(skillId) ?? []) + .find( + (suggestion) => + !context.purchasedServiceSkillIds.has( + suggestion.suggestedServiceSkillId, + ), + ); + + if (!matchingSuggestion) { + continue; + } + + targets.push( + this.toTargetCustomer(context, { + suggestedServiceName: + matchingSuggestion.suggestedServiceSkill?.service?.title ?? + matchingSuggestion.suggestedServiceSkill?.title ?? + 'خدمت مکمل', + }), + ); + } + + return targets; + } + + private toTargetCustomer( + context: SalonCustomerContext, + meta: Record, + ): CampaignTargetCustomer { + return { + customerId: context.customerId, + firstName: context.firstName, + lastName: context.lastName, + mobile: context.mobile, + totalPurchase: context.totalPurchase, + visitCount: context.visitCount, + lastVisitDate: context.lastVisitDate, + daysSinceVisit: context.daysSinceVisit, + estimatedRevenue: estimateVisitRevenue(context), + meta, + }; + } +} diff --git a/src/campaigns/campaigns.controller.ts b/src/campaigns/campaigns.controller.ts new file mode 100644 index 0000000..618b279 --- /dev/null +++ b/src/campaigns/campaigns.controller.ts @@ -0,0 +1,120 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + 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 { + ApiStandardController, + ApiStandardOkResponse, +} from '../swagger/decorators/swagger-response.decorator'; +import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup'; +import { UserRole } from '../users/enums/user-role.enum'; +import { CampaignAutomationService } from './campaign-automation.service'; +import { CampaignsService } from './campaigns.service'; +import { FindCampaignRunsQueryDto } from './dto/find-campaign-runs-query.dto'; +import { SendCampaignDto } from './dto/send-campaign.dto'; +import { CampaignRun } from './entities/campaign-run.entity'; +import { CampaignType } from './enums/campaign-type.enum'; + +@ApiTags('Campaigns') +@ApiBearerAuth(SWAGGER_JWT_AUTH) +@ApiStandardController() +@Controller('campaigns') +@UseGuards(RolesGuard, PermissionsGuard) +export class CampaignsController { + constructor( + private readonly campaignsService: CampaignsService, + private readonly campaignAutomationService: CampaignAutomationService, + ) {} + + @Roles(UserRole.SALON) + @ApiStandardOkResponse(Object, { isArray: true }) + @Get() + getOverview(@CurrentUser() user: AuthUser) { + return this.campaignsService.getOverview(user); + } + + @Roles(UserRole.SALON, UserRole.ADMIN) + @Permissions(PermissionCode.CAMPAIGNS_READ) + @ApiStandardOkResponse(CampaignRun, { isArray: true }) + @Get('runs') + findRuns( + @CurrentUser() user: AuthUser, + @Query() query: FindCampaignRunsQueryDto, + ) { + return this.campaignsService.findRuns(user, query); + } + + @Roles(UserRole.SALON, UserRole.ADMIN) + @Permissions(PermissionCode.CAMPAIGNS_READ) + @ApiStandardOkResponse(CampaignRun) + @Get('runs/:id') + findRun( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.campaignsService.findRun(id, user); + } + + @Roles(UserRole.SALON, UserRole.ADMIN) + @Permissions(PermissionCode.CAMPAIGNS_READ) + @ApiStandardOkResponse(Object, { isArray: true }) + @Get('runs/:id/recipients') + getRunRecipients( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.campaignsService.getRunRecipients(id, user); + } + + @Roles(UserRole.SALON) + @ApiStandardOkResponse(Object, { isArray: true }) + @Get(':type/targets') + getTargets(@Param('type') type: CampaignType, @CurrentUser() user: AuthUser) { + return this.campaignsService.getTargets(type, user); + } + + @Roles(UserRole.SALON) + @ApiStandardOkResponse(CampaignRun) + @Post(':type/send') + sendCampaign( + @Param('type') type: CampaignType, + @Body() dto: SendCampaignDto, + @CurrentUser() user: AuthUser, + ) { + return this.campaignsService.sendCampaign(type, dto, user); + } + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.CAMPAIGNS_WRITE) + @ApiStandardOkResponse(Object) + @Post('automation/trigger-returning') + async triggerReturningCustomers() { + const salonsProcessed = + await this.campaignAutomationService.runReturningCustomersCampaign(); + return { salonsProcessed }; + } + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.CAMPAIGNS_WRITE) + @ApiStandardOkResponse(Object) + @Post('automation/trigger-birthday') + async triggerBirthday() { + const salonsProcessed = + await this.campaignAutomationService.runBirthdayCampaign(); + return { salonsProcessed }; + } +} diff --git a/src/campaigns/campaigns.module.ts b/src/campaigns/campaigns.module.ts new file mode 100644 index 0000000..8e7c9e8 --- /dev/null +++ b/src/campaigns/campaigns.module.ts @@ -0,0 +1,43 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +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 { Salon } from '../salons/entities/salon.entity'; +import { SentMessage } from '../sent-messages/entities/sent-message.entity'; +import { SmsModule } from '../sms/sms.module'; +import { SmsSettingsModule } from '../sms-settings/sms-settings.module'; +import { ServiceSuggestion } from '../suggestions/entities/service-suggestion.entity'; +import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; +import { CampaignAutomationService } from './campaign-automation.service'; +import { CampaignTargetingService } from './campaign-targeting.service'; +import { CampaignsController } from './campaigns.controller'; +import { CampaignsService } from './campaigns.service'; +import { CampaignRun } from './entities/campaign-run.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Salon, + CampaignRun, + SentMessage, + SalonCustomer, + Reserve, + ServiceSuggestion, + ]), + SmsModule, + SmsSettingsModule, + SubscriptionsModule, + AuthorizationModule, + AuthGuardsModule, + ], + controllers: [CampaignsController], + providers: [ + CampaignsService, + CampaignTargetingService, + CampaignAutomationService, + ], + exports: [CampaignsService, CampaignTargetingService], +}) +export class CampaignsModule {} diff --git a/src/campaigns/campaigns.service.ts b/src/campaigns/campaigns.service.ts new file mode 100644 index 0000000..acee9a9 --- /dev/null +++ b/src/campaigns/campaigns.service.ts @@ -0,0 +1,357 @@ +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 { AuthorizationService } from '../authorization/authorization.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'; +import { SmsDispatchService } from '../sms-settings/sms-dispatch.service'; +import { SmsService } from '../sms/sms.service'; +import { SalonSubscriptionsService } from '../subscriptions/salon-subscriptions.service'; +import { UserRole } from '../users/enums/user-role.enum'; +import { + CampaignTargetCustomer, + CampaignTargetingService, +} from './campaign-targeting.service'; +import { + CAMPAIGN_TYPES, + getCampaignTypeDefinition, +} from './constants/campaign-types.constants'; +import { FindCampaignRunsQueryDto } from './dto/find-campaign-runs-query.dto'; +import { SendCampaignDto } from './dto/send-campaign.dto'; +import { CampaignRun } from './entities/campaign-run.entity'; +import { CampaignRunStatus } from './enums/campaign-run-status.enum'; +import { CampaignTrigger } from './enums/campaign-trigger.enum'; +import { CampaignType } from './enums/campaign-type.enum'; + +export interface CampaignOverviewItem { + type: CampaignType; + label: string; + description: string; + targetingNote: string; + icon: string; + color: string; + trigger: CampaignTrigger; + targetCount: number; + estimatedRevenue: number; +} + +@Injectable() +export class CampaignsService { + constructor( + @InjectRepository(Salon) + private readonly salonsRepository: Repository, + @InjectRepository(CampaignRun) + private readonly campaignRunsRepository: Repository, + @InjectRepository(SentMessage) + private readonly sentMessagesRepository: Repository, + private readonly targetingService: CampaignTargetingService, + private readonly smsDispatchService: SmsDispatchService, + private readonly smsService: SmsService, + private readonly salonSubscriptionsService: SalonSubscriptionsService, + private readonly authorizationService: AuthorizationService, + ) {} + + async getOverview(user: AuthUser): Promise { + const salon = await this.resolveSalonForUser(user); + const contexts = await this.targetingService.getSalonCustomerContexts( + salon.id, + ); + + return Promise.all( + CAMPAIGN_TYPES.map(async (definition) => { + const targets = await this.targetingService.filterContextsForType( + definition.type, + contexts, + ); + + return { + type: definition.type, + label: definition.label, + description: definition.description, + targetingNote: definition.targetingNote, + icon: definition.icon, + color: definition.color, + trigger: definition.trigger, + targetCount: targets.length, + estimatedRevenue: this.sumEstimatedRevenue(targets), + }; + }), + ); + } + + async getTargets( + type: CampaignType, + user: AuthUser, + ): Promise { + this.ensureValidType(type); + const salon = await this.resolveSalonForUser(user); + return this.targetingService.getTargetsForType(type, salon.id); + } + + async sendCampaign( + type: CampaignType, + dto: SendCampaignDto, + user: AuthUser, + ): Promise { + const definition = this.ensureValidType(type); + if (definition.trigger !== CampaignTrigger.MANUAL) { + throw new BadRequestException( + 'این کمپین به‌صورت خودکار توسط سیستم ارسال می‌شود', + ); + } + + const salon = await this.resolveSalonForUser(user); + const allTargets = await this.targetingService.getTargetsForType( + type, + salon.id, + ); + + const targets = dto.customerIds + ? allTargets.filter((target) => + dto.customerIds!.includes(target.customerId), + ) + : allTargets; + + if (targets.length === 0) { + throw new BadRequestException('مشتری هدفی برای این کمپین یافت نشد'); + } + + const sampleMessage = await this.smsDispatchService.resolveMessage( + definition.scenarioKey, + {}, + ); + if (!sampleMessage) { + throw new BadRequestException( + 'متن پیامک این کمپین در تنظیمات پیامک ادمین فعال نشده است', + ); + } + + await this.salonSubscriptionsService.consumeSms(salon.id, targets.length); + + const run = await this.runCampaignForSalon({ + salon, + type, + trigger: CampaignTrigger.MANUAL, + targets, + triggeredByUserId: user.id, + }); + + return this.findRun(run.id, user); + } + + /** + * Creates a campaign run and dispatches SMS to every target. Used by both the + * manual send endpoint and the automatic (cron-driven) campaigns. SMS balance + * must already have been consumed by the caller before invoking this method. + */ + async runCampaignForSalon(params: { + salon: Salon; + type: CampaignType; + trigger: CampaignTrigger; + targets: CampaignTargetCustomer[]; + triggeredByUserId?: string | null; + }): Promise { + const definition = getCampaignTypeDefinition(params.type); + + const run = await this.campaignRunsRepository.save( + this.campaignRunsRepository.create({ + salonId: params.salon.id, + type: params.type, + trigger: params.trigger, + status: CampaignRunStatus.COMPLETED, + targetCount: params.targets.length, + sentCount: 0, + failedCount: 0, + estimatedRevenue: this.sumEstimatedRevenue(params.targets), + triggeredByUserId: params.triggeredByUserId ?? null, + }), + ); + + await this.dispatchToTargets( + run, + params.salon, + definition.scenarioKey, + params.targets, + ); + + return run; + } + + /** Records a campaign run that could not be sent (e.g. insufficient SMS balance). */ + async recordFailedRun(params: { + salon: Salon; + type: CampaignType; + trigger: CampaignTrigger; + targetCount: number; + estimatedRevenue: number; + }): Promise { + return this.campaignRunsRepository.save( + this.campaignRunsRepository.create({ + salonId: params.salon.id, + type: params.type, + trigger: params.trigger, + status: CampaignRunStatus.FAILED, + targetCount: params.targetCount, + sentCount: 0, + failedCount: params.targetCount, + estimatedRevenue: params.estimatedRevenue, + triggeredByUserId: null, + }), + ); + } + + sumEstimatedRevenue(targets: CampaignTargetCustomer[]): number { + return targets.reduce((sum, target) => sum + target.estimatedRevenue, 0); + } + + async findRuns( + user: AuthUser, + query: FindCampaignRunsQueryDto, + ): Promise { + const qb = this.campaignRunsRepository + .createQueryBuilder('run') + .leftJoinAndSelect('run.salon', 'salon') + .orderBy('run.createdAt', 'DESC'); + + if (query.type) { + qb.andWhere('run.type = :type', { type: query.type }); + } + if (query.status) { + qb.andWhere('run.status = :status', { status: query.status }); + } + + if (this.authorizationService.isAdmin(user)) { + if (query.salonId) { + qb.andWhere('run.salonId = :salonId', { salonId: query.salonId }); + } + } else if (user.role === UserRole.SALON) { + qb.andWhere('salon.ownerId = :ownerId', { ownerId: user.id }); + } else { + throw new ForbiddenException(); + } + + return qb.getMany(); + } + + async findRun(id: string, user: AuthUser): Promise { + const run = await this.campaignRunsRepository.findOne({ + where: { id }, + relations: { salon: true, triggeredByUser: true }, + }); + if (!run) { + throw new NotFoundException(`Campaign run #${id} not found`); + } + this.ensureCanAccessSalon(user, run.salon.ownerId); + return run; + } + + async getRunRecipients(id: string, user: AuthUser): Promise { + await this.findRun(id, user); + return this.sentMessagesRepository.find({ + where: { campaignRunId: id }, + relations: { customer: { user: true } }, + order: { createdAt: 'DESC' }, + }); + } + + private async dispatchToTargets( + run: CampaignRun, + salon: Salon, + scenarioKey: (typeof CAMPAIGN_TYPES)[number]['scenarioKey'], + targets: CampaignTargetCustomer[], + ): Promise { + let sentCount = 0; + let failedCount = 0; + + for (const target of targets) { + const variables: Record = { + customerName: + `${target.firstName} ${target.lastName}`.trim() || 'مشتری', + salonName: salon.name, + ...target.meta, + }; + + const message = await this.smsDispatchService.resolveMessage( + scenarioKey, + variables, + ); + + let status = SentMessageStatus.FAILED; + if (message) { + const sent = await this.smsService.sendSms(target.mobile, message); + status = sent ? SentMessageStatus.SENT : SentMessageStatus.FAILED; + } + + if (status === SentMessageStatus.SENT) { + sentCount += 1; + } else { + failedCount += 1; + } + + await this.sentMessagesRepository.save( + this.sentMessagesRepository.create({ + salonId: salon.id, + customerId: target.customerId, + reserveId: null, + campaignRunId: run.id, + mobile: target.mobile, + body: message ?? '', + status, + }), + ); + } + + run.sentCount = sentCount; + run.failedCount = failedCount; + run.status = + failedCount === 0 + ? CampaignRunStatus.COMPLETED + : sentCount === 0 + ? CampaignRunStatus.FAILED + : CampaignRunStatus.PARTIAL; + + await this.campaignRunsRepository.save(run); + } + + private ensureValidType(type: CampaignType) { + try { + return getCampaignTypeDefinition(type); + } catch { + throw new NotFoundException(`Campaign type "${type}" not found`); + } + } + + private async resolveSalonForUser(user: AuthUser): Promise { + if (user.role !== UserRole.SALON) { + throw new ForbiddenException(); + } + + const salon = await this.salonsRepository.findOne({ + where: { ownerId: user.id }, + order: { createdAt: 'ASC' }, + }); + + if (!salon) { + throw new NotFoundException('Salon not found for this user'); + } + + return salon; + } + + private ensureCanAccessSalon(user: AuthUser, ownerId: string): void { + if (this.authorizationService.isAdmin(user)) { + return; + } + if (user.role === UserRole.SALON && ownerId === user.id) { + return; + } + throw new ForbiddenException(); + } +} diff --git a/src/campaigns/constants/campaign-types.constants.ts b/src/campaigns/constants/campaign-types.constants.ts new file mode 100644 index 0000000..1250476 --- /dev/null +++ b/src/campaigns/constants/campaign-types.constants.ts @@ -0,0 +1,100 @@ +import { SmsScenarioKey } from '../../sms-settings/enums/sms-scenario-key.enum'; +import { CampaignTrigger } from '../enums/campaign-trigger.enum'; +import { CampaignType } from '../enums/campaign-type.enum'; + +/** How many days before the renewal due date the "returning customers" reminder fires (also fires on day 0). */ +export const RENEWAL_REMINDER_LEAD_DAYS = 4; + +/** Customers whose birthday falls within this many days are shown as upcoming targets. */ +export const BIRTHDAY_UPCOMING_WINDOW_DAYS = 7; + +/** Fixed inactivity threshold (in days) used by the "inactive customers" campaign, independent of renewal cycles. */ +export const INACTIVE_CUSTOMER_DAYS_THRESHOLD = 60; + +export interface CampaignTypeDefinition { + type: CampaignType; + label: string; + description: string; + targetingNote: string; + icon: 'send' | 'gift' | 'alert-triangle' | 'crown' | 'refresh-ccw' | 'puzzle'; + color: string; + trigger: CampaignTrigger; + scenarioKey: SmsScenarioKey; +} + +export const CAMPAIGN_TYPES: CampaignTypeDefinition[] = [ + { + type: CampaignType.RETURNING_CUSTOMERS, + label: 'کمپین بازگشت مشتریان (تجدید خدمت)', + description: 'مشتریانی که زمان تجدید خدمت آن‌ها رسیده است.', + targetingNote: 'بر اساس دوره تجدید', + icon: 'send', + color: '#F9607D', + trigger: CampaignTrigger.AUTOMATIC, + scenarioKey: SmsScenarioKey.CAMPAIGN_RETURNING_CUSTOMERS, + }, + { + type: CampaignType.BIRTHDAY, + label: 'کمپین تولد مشتری', + description: 'مشتریانی که تا ۷ روز آینده تولد دارند.', + targetingNote: 'تا ۷ روز آینده', + icon: 'gift', + color: '#F59E0B', + trigger: CampaignTrigger.AUTOMATIC, + scenarioKey: SmsScenarioKey.CAMPAIGN_BIRTHDAY, + }, + { + type: CampaignType.AT_RISK, + label: 'کمپین مشتریان در خطر ریزش', + description: 'مشتریانی که کمی دیر مراجعه کرده‌اند و در خطر ریزش هستند.', + targetingNote: 'تا ۳۰ روز از موعد تجدید', + icon: 'alert-triangle', + color: '#7C3AED', + trigger: CampaignTrigger.MANUAL, + scenarioKey: SmsScenarioKey.CAMPAIGN_AT_RISK, + }, + { + type: CampaignType.VIP, + label: 'کمپین مشتریان VIP', + description: 'مشتریان VIP و پرخرید، با پیشنهاد ویژه.', + targetingNote: 'مشتریان VIP', + icon: 'crown', + color: '#7C3AED', + trigger: CampaignTrigger.MANUAL, + scenarioKey: SmsScenarioKey.CAMPAIGN_VIP, + }, + { + type: CampaignType.INACTIVE, + label: 'کمپین مشتریان غیرفعال', + description: 'مشتریانی که بیش از ۶۰ روز مراجعه نکرده‌اند.', + targetingNote: '۶۰ روز گذشته', + icon: 'refresh-ccw', + color: '#22C55E', + trigger: CampaignTrigger.MANUAL, + scenarioKey: SmsScenarioKey.CAMPAIGN_INACTIVE, + }, + { + type: CampaignType.COMPLEMENTARY_SERVICE, + label: 'کمپین خدمت مکمل', + description: 'پیشنهاد خدمات مکمل بر اساس خدمات قبلی.', + targetingNote: 'بر اساس خدمات قبلی', + icon: 'puzzle', + color: '#3B82F6', + trigger: CampaignTrigger.MANUAL, + scenarioKey: SmsScenarioKey.CAMPAIGN_COMPLEMENTARY, + }, +]; + +export const CAMPAIGN_TYPE_MAP = new Map( + CAMPAIGN_TYPES.map((definition) => [definition.type, definition]), +); + +export function getCampaignTypeDefinition( + type: CampaignType, +): CampaignTypeDefinition { + const definition = CAMPAIGN_TYPE_MAP.get(type); + if (!definition) { + throw new Error(`Unknown campaign type "${type}"`); + } + return definition; +} diff --git a/src/campaigns/dto/find-campaign-runs-query.dto.ts b/src/campaigns/dto/find-campaign-runs-query.dto.ts new file mode 100644 index 0000000..9040a9f --- /dev/null +++ b/src/campaigns/dto/find-campaign-runs-query.dto.ts @@ -0,0 +1,21 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsUUID } from 'class-validator'; +import { CampaignRunStatus } from '../enums/campaign-run-status.enum'; +import { CampaignType } from '../enums/campaign-type.enum'; + +export class FindCampaignRunsQueryDto { + @ApiPropertyOptional({ description: 'Filter by salon (admin only)' }) + @IsOptional() + @IsUUID() + salonId?: string; + + @ApiPropertyOptional({ enum: CampaignType }) + @IsOptional() + @IsEnum(CampaignType) + type?: CampaignType; + + @ApiPropertyOptional({ enum: CampaignRunStatus }) + @IsOptional() + @IsEnum(CampaignRunStatus) + status?: CampaignRunStatus; +} diff --git a/src/campaigns/dto/send-campaign.dto.ts b/src/campaigns/dto/send-campaign.dto.ts new file mode 100644 index 0000000..b3960ce --- /dev/null +++ b/src/campaigns/dto/send-campaign.dto.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsOptional, IsUUID } from 'class-validator'; + +export class SendCampaignDto { + @ApiPropertyOptional({ + description: + 'Optional subset of currently targeted customer ids to send to. When omitted, all current targets receive the campaign.', + type: [String], + }) + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + customerIds?: string[]; +} diff --git a/src/campaigns/entities/campaign-run.entity.ts b/src/campaigns/entities/campaign-run.entity.ts new file mode 100644 index 0000000..b2dc8dd --- /dev/null +++ b/src/campaigns/entities/campaign-run.entity.ts @@ -0,0 +1,85 @@ +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 { CampaignRunStatus } from '../enums/campaign-run-status.enum'; +import { CampaignTrigger } from '../enums/campaign-trigger.enum'; +import { CampaignType } from '../enums/campaign-type.enum'; + +@Entity('campaign_runs') +@Index(['salonId', 'createdAt']) +@Index(['salonId', 'type']) +export class CampaignRun { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + salonId: string; + + @ManyToOne(() => Salon, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'salonId' }) + salon: Salon; + + @Column({ + type: 'enum', + enum: CampaignType, + enumName: 'campaign_type_enum', + }) + type: CampaignType; + + @Column({ + type: 'enum', + enum: CampaignTrigger, + enumName: 'campaign_trigger_enum', + }) + trigger: CampaignTrigger; + + @Column({ + type: 'enum', + enum: CampaignRunStatus, + enumName: 'campaign_run_status_enum', + }) + status: CampaignRunStatus; + + @Column({ type: 'int', default: 0 }) + targetCount: number; + + @Column({ type: 'int', default: 0 }) + sentCount: number; + + @Column({ type: 'int', default: 0 }) + failedCount: number; + + @Column({ + type: 'decimal', + precision: 14, + scale: 2, + default: 0, + transformer: { + to: (value: number) => value, + from: (value: string) => parseFloat(value), + }, + }) + estimatedRevenue: number; + + @Column({ type: 'uuid', nullable: true }) + triggeredByUserId: string | null; + + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'triggeredByUserId' }) + triggeredByUser: User | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/campaigns/enums/campaign-run-status.enum.ts b/src/campaigns/enums/campaign-run-status.enum.ts new file mode 100644 index 0000000..fd83dea --- /dev/null +++ b/src/campaigns/enums/campaign-run-status.enum.ts @@ -0,0 +1,5 @@ +export enum CampaignRunStatus { + COMPLETED = 'completed', + PARTIAL = 'partial', + FAILED = 'failed', +} diff --git a/src/campaigns/enums/campaign-trigger.enum.ts b/src/campaigns/enums/campaign-trigger.enum.ts new file mode 100644 index 0000000..d079fd6 --- /dev/null +++ b/src/campaigns/enums/campaign-trigger.enum.ts @@ -0,0 +1,4 @@ +export enum CampaignTrigger { + AUTOMATIC = 'automatic', + MANUAL = 'manual', +} diff --git a/src/campaigns/enums/campaign-type.enum.ts b/src/campaigns/enums/campaign-type.enum.ts new file mode 100644 index 0000000..ad6ad8a --- /dev/null +++ b/src/campaigns/enums/campaign-type.enum.ts @@ -0,0 +1,8 @@ +export enum CampaignType { + RETURNING_CUSTOMERS = 'returning_customers', + BIRTHDAY = 'birthday', + AT_RISK = 'at_risk', + VIP = 'vip', + INACTIVE = 'inactive', + COMPLEMENTARY_SERVICE = 'complementary_service', +} diff --git a/src/database/migrations/1721200000000-AddSmsScenarioSettings.ts b/src/database/migrations/1721200000000-AddSmsScenarioSettings.ts new file mode 100644 index 0000000..1a5aaa7 --- /dev/null +++ b/src/database/migrations/1721200000000-AddSmsScenarioSettings.ts @@ -0,0 +1,85 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, +} from 'typeorm'; +import { SMS_SCENARIOS } from '../../sms-settings/constants/sms-scenarios.constants'; + +export class AddSmsScenarioSettings1721200000000 implements MigrationInterface { + name = 'AddSmsScenarioSettings1721200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "sms_scenario_key_enum" AS ENUM ( + 'otp_login', + 'reserve_created', + 'reserve_reminder', + 'reserve_confirmed', + 'reserve_cancelled' + ) + `); + + await queryRunner.createTable( + new Table({ + name: 'sms_scenario_settings', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { + name: 'key', + type: 'sms_scenario_key_enum', + isUnique: true, + }, + { name: 'body', type: 'text', isNullable: true }, + { name: 'templateId', type: 'uuid', isNullable: true }, + { name: 'isActive', type: 'boolean', default: true }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'sms_scenario_settings', + new TableIndex({ + name: 'IDX_sms_scenario_settings_key', + columnNames: ['key'], + isUnique: true, + }), + ); + + await queryRunner.createForeignKey( + 'sms_scenario_settings', + new TableForeignKey({ + name: 'FK_sms_scenario_settings_templateId', + columnNames: ['templateId'], + referencedTableName: 'sms_templates', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + for (const scenario of SMS_SCENARIOS) { + await queryRunner.query( + ` + INSERT INTO sms_scenario_settings (key, body, "isActive") + VALUES ($1, $2, true) + `, + [scenario.key, scenario.defaultBody], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('sms_scenario_settings', true); + await queryRunner.query('DROP TYPE "sms_scenario_key_enum"'); + } +} diff --git a/src/database/migrations/1721300000000-AddCampaigns.ts b/src/database/migrations/1721300000000-AddCampaigns.ts new file mode 100644 index 0000000..76dccfc --- /dev/null +++ b/src/database/migrations/1721300000000-AddCampaigns.ts @@ -0,0 +1,218 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, +} from 'typeorm'; +import { SmsScenarioKey } from '../../sms-settings/enums/sms-scenario-key.enum'; +import { SMS_SCENARIOS } from '../../sms-settings/constants/sms-scenarios.constants'; + +const NEW_SCENARIO_KEYS = [ + SmsScenarioKey.CAMPAIGN_RETURNING_CUSTOMERS, + SmsScenarioKey.CAMPAIGN_BIRTHDAY, + SmsScenarioKey.CAMPAIGN_AT_RISK, + SmsScenarioKey.CAMPAIGN_VIP, + SmsScenarioKey.CAMPAIGN_INACTIVE, + SmsScenarioKey.CAMPAIGN_COMPLEMENTARY, +]; + +export class AddCampaigns1721300000000 implements MigrationInterface { + name = 'AddCampaigns1721300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await this.extendSmsScenarioEnum(queryRunner); + await this.seedCampaignScenarioSettings(queryRunner); + await this.createCampaignEnums(queryRunner); + await this.createCampaignRunsTable(queryRunner); + await this.addCampaignRunIdToSentMessages(queryRunner); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "sent_messages" DROP CONSTRAINT IF EXISTS "FK_sent_messages_campaignRunId"`, + ); + await queryRunner.query( + `ALTER TABLE "sent_messages" DROP COLUMN IF EXISTS "campaignRunId"`, + ); + await queryRunner.dropTable('campaign_runs', true); + await queryRunner.query('DROP TYPE IF EXISTS "campaign_run_status_enum"'); + await queryRunner.query('DROP TYPE IF EXISTS "campaign_trigger_enum"'); + await queryRunner.query('DROP TYPE IF EXISTS "campaign_type_enum"'); + await queryRunner.query( + `DELETE FROM "sms_scenario_settings" WHERE "key" IN (${NEW_SCENARIO_KEYS.map((key) => `'${key}'`).join(', ')})`, + ); + // Enum values cannot be dropped without recreating the type; left as-is on downgrade. + } + + private async extendSmsScenarioEnum(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "sms_scenario_key_enum" RENAME TO "sms_scenario_key_enum_old"`, + ); + + await queryRunner.query(` + CREATE TYPE "sms_scenario_key_enum" AS ENUM ( + 'otp_login', + 'reserve_created', + 'reserve_reminder', + 'reserve_confirmed', + 'reserve_cancelled', + 'campaign_returning_customers', + 'campaign_birthday', + 'campaign_at_risk', + 'campaign_vip', + 'campaign_inactive', + 'campaign_complementary' + ) + `); + + await queryRunner.query(` + ALTER TABLE "sms_scenario_settings" + ALTER COLUMN "key" TYPE "sms_scenario_key_enum" + USING "key"::text::"sms_scenario_key_enum" + `); + + await queryRunner.query('DROP TYPE "sms_scenario_key_enum_old"'); + } + + private async seedCampaignScenarioSettings( + queryRunner: QueryRunner, + ): Promise { + for (const key of NEW_SCENARIO_KEYS) { + const definition = SMS_SCENARIOS.find((scenario) => scenario.key === key); + if (!definition) { + continue; + } + + await queryRunner.query( + ` + INSERT INTO sms_scenario_settings (key, body, "isActive") + VALUES ($1, $2, true) + ON CONFLICT (key) DO NOTHING + `, + [definition.key, definition.defaultBody], + ); + } + } + + private async createCampaignEnums(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "campaign_type_enum" AS ENUM ( + 'returning_customers', + 'birthday', + 'at_risk', + 'vip', + 'inactive', + 'complementary_service' + ) + `); + + await queryRunner.query(` + CREATE TYPE "campaign_trigger_enum" AS ENUM ('automatic', 'manual') + `); + + await queryRunner.query(` + CREATE TYPE "campaign_run_status_enum" AS ENUM ('completed', 'partial', 'failed') + `); + } + + private async createCampaignRunsTable( + queryRunner: QueryRunner, + ): Promise { + await queryRunner.createTable( + new Table({ + name: 'campaign_runs', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'salonId', type: 'uuid' }, + { name: 'type', type: 'campaign_type_enum' }, + { name: 'trigger', type: 'campaign_trigger_enum' }, + { name: 'status', type: 'campaign_run_status_enum' }, + { name: 'targetCount', type: 'int', default: 0 }, + { name: 'sentCount', type: 'int', default: 0 }, + { name: 'failedCount', type: 'int', default: 0 }, + { + name: 'estimatedRevenue', + type: 'decimal', + precision: 14, + scale: 2, + default: 0, + }, + { name: 'triggeredByUserId', type: 'uuid', isNullable: true }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'campaign_runs', + new TableForeignKey({ + columnNames: ['salonId'], + referencedTableName: 'salons', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'campaign_runs', + new TableForeignKey({ + columnNames: ['triggeredByUserId'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.createIndex( + 'campaign_runs', + new TableIndex({ + name: 'IDX_campaign_runs_salonId_createdAt', + columnNames: ['salonId', 'createdAt'], + }), + ); + + await queryRunner.createIndex( + 'campaign_runs', + new TableIndex({ + name: 'IDX_campaign_runs_salonId_type', + columnNames: ['salonId', 'type'], + }), + ); + } + + private async addCampaignRunIdToSentMessages( + queryRunner: QueryRunner, + ): Promise { + await queryRunner.query( + `ALTER TABLE "sent_messages" ADD COLUMN "campaignRunId" uuid NULL`, + ); + + await queryRunner.createForeignKey( + 'sent_messages', + new TableForeignKey({ + name: 'FK_sent_messages_campaignRunId', + columnNames: ['campaignRunId'], + referencedTableName: 'campaign_runs', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.createIndex( + 'sent_messages', + new TableIndex({ + name: 'IDX_sent_messages_campaignRunId', + columnNames: ['campaignRunId'], + }), + ); + } +} diff --git a/src/reserves/reserves.module.ts b/src/reserves/reserves.module.ts index ac692eb..c94399a 100644 --- a/src/reserves/reserves.module.ts +++ b/src/reserves/reserves.module.ts @@ -6,6 +6,7 @@ import { CustomersModule } from '../customers/customers.module'; import { SalonsModule } from '../salons/salons.module'; import { ServiceSkill } from '../services/entities/service-skill.entity'; import { Service } from '../services/entities/service.entity'; +import { SmsSettingsModule } from '../sms-settings/sms-settings.module'; import { StylistsModule } from '../stylists/stylists.module'; import { ReserveItem } from './entities/reserve-item.entity'; import { ReserveStatusHistory } from './entities/reserve-status-history.entity'; @@ -27,6 +28,7 @@ import { ReservesService } from './reserves.service'; StylistsModule, AuthorizationModule, AuthGuardsModule, + SmsSettingsModule, ], controllers: [ReservesController], providers: [ReservesService], diff --git a/src/reserves/reserves.service.ts b/src/reserves/reserves.service.ts index ece07e6..af8030e 100644 --- a/src/reserves/reserves.service.ts +++ b/src/reserves/reserves.service.ts @@ -21,6 +21,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 { ReserveSmsService } from '../sms-settings/reserve-sms.service'; import { ReserveStatus } from './enums/reserve-status.enum'; @Injectable() @@ -41,6 +42,7 @@ export class ReservesService { private readonly stylistsService: StylistsService, private readonly authorizationService: AuthorizationService, private readonly reservePolicy: ReservePolicy, + private readonly reserveSmsService: ReserveSmsService, ) {} async create( @@ -94,6 +96,7 @@ export class ReservesService { const saved = await this.reservesRepository.save(reserve); await this.customersService.linkToSalon(saved.salonId, saved.customerId); await this.logStatusChange(saved.id, null, saved.status, user.id); + await this.reserveSmsService.sendReserveCreated(saved.id); return this.findOne(saved.id, user); } @@ -235,6 +238,7 @@ export class ReservesService { } if (status !== undefined) { + const previousStatus = reserve.status; if (status !== reserve.status) { await this.logStatusChange(reserve.id, reserve.status, status, user.id); } @@ -244,7 +248,20 @@ export class ReservesService { } else if (cancellationReason !== undefined) { reserve.cancellationReason = cancellationReason; } - } else if (cancellationReason !== undefined) { + + await this.reservesRepository.save(reserve); + + if ( + status === ReserveStatus.CANCELLED && + previousStatus !== ReserveStatus.CANCELLED + ) { + await this.reserveSmsService.sendReserveCancelled(reserve.id); + } + + return this.findOne(id, user); + } + + if (cancellationReason !== undefined) { reserve.cancellationReason = cancellationReason; } @@ -290,6 +307,7 @@ export class ReservesService { reserve.status = ReserveStatus.CONFIRMED; await this.reservesRepository.save(reserve); + await this.reserveSmsService.sendReserveConfirmed(reserve.id); } private async logStatusChange( diff --git a/src/sent-messages/entities/sent-message.entity.ts b/src/sent-messages/entities/sent-message.entity.ts index 938e9b9..beeb3a5 100644 --- a/src/sent-messages/entities/sent-message.entity.ts +++ b/src/sent-messages/entities/sent-message.entity.ts @@ -8,6 +8,7 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import { CampaignRun } from '../../campaigns/entities/campaign-run.entity'; import { Customer } from '../../customers/entities/customer.entity'; import { Reserve } from '../../reserves/entities/reserve.entity'; import { Salon } from '../../salons/entities/salon.entity'; @@ -18,6 +19,7 @@ import { SentMessageStatus } from '../enums/sent-message-status.enum'; @Index(['customerId']) @Index(['reserveId']) @Index(['status']) +@Index(['campaignRunId']) export class SentMessage { @PrimaryGeneratedColumn('uuid') id: string; @@ -43,6 +45,13 @@ export class SentMessage { @JoinColumn({ name: 'reserveId' }) reserve: Reserve | null; + @Column({ type: 'uuid', nullable: true }) + campaignRunId: string | null; + + @ManyToOne(() => CampaignRun, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'campaignRunId' }) + campaignRun: CampaignRun | null; + @Column({ length: 11 }) mobile: string; diff --git a/src/sms-settings/constants/sms-scenarios.constants.ts b/src/sms-settings/constants/sms-scenarios.constants.ts new file mode 100644 index 0000000..0a23b23 --- /dev/null +++ b/src/sms-settings/constants/sms-scenarios.constants.ts @@ -0,0 +1,411 @@ +import { SmsScenarioKey } from '../enums/sms-scenario-key.enum'; + +export interface SmsScenarioVariable { + key: string; + label: string; + description: string; + sampleValue: string; +} + +export interface SmsScenarioDefinition { + key: SmsScenarioKey; + label: string; + description: string; + defaultBody: string; + variables: SmsScenarioVariable[]; +} + +export const SMS_SCENARIOS: SmsScenarioDefinition[] = [ + { + key: SmsScenarioKey.OTP_LOGIN, + label: 'ورود با کد یکبار مصرف', + description: 'پیامک ارسالی هنگام درخواست کد ورود برای مشتری، سالن و ادمین', + defaultBody: 'کد ورود: {code}\nاعتبار: {expiresInMinutes} دقیقه', + variables: [ + { + key: 'code', + label: 'کد ورود', + description: 'کد یکبار مصرف ارسالی به کاربر', + sampleValue: '12345', + }, + { + key: 'expiresInMinutes', + label: 'مدت اعتبار', + description: 'زمان اعتبار کد به دقیقه', + sampleValue: '5', + }, + ], + }, + { + key: SmsScenarioKey.RESERVE_CREATED, + label: 'ثبت رزرو', + description: 'پیامک ارسالی به مشتری پس از ثبت رزرو جدید', + defaultBody: + '{customerName} عزیز، رزرو شما در {salonName} ثبت شد.\nتاریخ: {appointmentDate}\nساعت: {startTime} تا {endTime}\nمتخصص: {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: 'depositAmount', + label: 'مبلغ بیعانه', + description: 'مبلغ بیعانه به تومان', + sampleValue: '۵۰۰,۰۰۰', + }, + ], + }, + { + key: SmsScenarioKey.RESERVE_REMINDER, + label: 'یادآوری رزرو', + description: + 'پیامک یادآوری برای رزروهای روز بعد (رزروهایی که برای روز دیگری ثبت شده‌اند)', + defaultBody: + '{customerName} عزیز، یادآوری نوبت شما در {salonName}.\nتاریخ: {appointmentDate}\nساعت: {startTime}\nمتخصص: {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: SmsScenarioKey.RESERVE_CONFIRMED, + label: 'تأیید رزرو', + description: 'پیامک ارسالی پس از پرداخت بیعانه و تأیید رزرو', + defaultBody: + '{customerName} عزیز، رزرو شما در {salonName} تأیید شد.\nتاریخ: {appointmentDate}\nساعت: {startTime}\nبیعانه: {depositAmount} تومان', + variables: [ + { + key: 'customerName', + label: 'نام مشتری', + description: 'نام و نام خانوادگی مشتری', + sampleValue: 'مریم احمدی', + }, + { + key: 'salonName', + label: 'نام سالن', + description: 'نام سالن زیبایی', + sampleValue: 'سالن زیبایی رز', + }, + { + key: 'appointmentDate', + label: 'تاریخ نوبت', + description: 'تاریخ رزرو', + sampleValue: '۱۴۰۴/۰۴/۲۰', + }, + { + key: 'startTime', + label: 'ساعت شروع', + description: 'ساعت شروع نوبت', + sampleValue: '۱۰:۰۰', + }, + { + key: 'totalAmount', + label: 'مبلغ کل', + description: 'مبلغ کل رزرو به تومان', + sampleValue: '۱,۵۰۰,۰۰۰', + }, + { + key: 'depositAmount', + label: 'مبلغ بیعانه', + description: 'مبلغ بیعانه به تومان', + sampleValue: '۵۰۰,۰۰۰', + }, + ], + }, + { + key: SmsScenarioKey.RESERVE_CANCELLED, + label: 'لغو رزرو', + description: 'پیامک ارسالی هنگام لغو رزرو', + defaultBody: + '{customerName} عزیز، رزرو شما در {salonName} لغو شد.\nتاریخ: {appointmentDate}\nساعت: {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: SmsScenarioKey.CAMPAIGN_RETURNING_CUSTOMERS, + label: 'کمپین بازگشت مشتریان (تجدید خدمت)', + description: + 'پیامک خودکار برای مشتریانی که زمان تجدید خدمت آن‌ها نزدیک است یا رسیده است (۴ روز قبل و روز موعد)', + defaultBody: + '{customerName} عزیز، زمان تجدید {lastServiceName} شما در {salonName} نزدیک است. منتظر دیدار دوباره شما هستیم 🌸', + variables: [ + { + key: 'customerName', + label: 'نام مشتری', + description: 'نام و نام خانوادگی مشتری', + sampleValue: 'مریم احمدی', + }, + { + key: 'salonName', + label: 'نام سالن', + description: 'نام سالن زیبایی', + sampleValue: 'سالن زیبایی رز', + }, + { + key: 'lastServiceName', + label: 'آخرین خدمت', + description: 'نام آخرین خدمت دریافت‌شده توسط مشتری', + sampleValue: 'رنگ مو', + }, + { + key: 'daysUntilRenewal', + label: 'روز تا موعد تجدید', + description: 'تعداد روز باقی‌مانده تا موعد تجدید (۰ یعنی امروز)', + sampleValue: '۴', + }, + ], + }, + { + key: SmsScenarioKey.CAMPAIGN_BIRTHDAY, + label: 'کمپین تولد مشتری', + description: + 'پیامک تبریک تولد که به‌صورت خودکار در روز تولد مشتری ارسال می‌شود', + defaultBody: + 'تولدتان مبارک {customerName} عزیز 🎂 تیم {salonName} این روز خوش را به شما تبریک می‌گوید.', + variables: [ + { + key: 'customerName', + label: 'نام مشتری', + description: 'نام و نام خانوادگی مشتری', + sampleValue: 'مریم احمدی', + }, + { + key: 'salonName', + label: 'نام سالن', + description: 'نام سالن زیبایی', + sampleValue: 'سالن زیبایی رز', + }, + ], + }, + { + key: SmsScenarioKey.CAMPAIGN_AT_RISK, + label: 'کمپین مشتریان در خطر ریزش', + description: + 'کمپینی که سالن به‌صورت دستی برای مشتریانی که کمی دیر مراجعه کرده‌اند ارسال می‌کند', + defaultBody: + '{customerName} عزیز، مدتی است به {salonName} سر نزده‌اید. با یک پیشنهاد ویژه منتظر شما هستیم 💕', + variables: [ + { + key: 'customerName', + label: 'نام مشتری', + description: 'نام و نام خانوادگی مشتری', + sampleValue: 'مریم احمدی', + }, + { + key: 'salonName', + label: 'نام سالن', + description: 'نام سالن زیبایی', + sampleValue: 'سالن زیبایی رز', + }, + { + key: 'daysSinceVisit', + label: 'روزهای از آخرین مراجعه', + description: 'تعداد روزهایی که از آخرین مراجعه مشتری گذشته است', + sampleValue: '۴۰', + }, + ], + }, + { + key: SmsScenarioKey.CAMPAIGN_VIP, + label: 'کمپین مشتریان VIP', + description: + 'کمپینی که سالن به‌صورت دستی برای مشتریان VIP و پرخرید ارسال می‌کند', + defaultBody: + '{customerName} عزیز، به‌عنوان مشتری ویژه {salonName} یک پیشنهاد خاص برای شما در نظر گرفته‌ایم ⭐', + variables: [ + { + key: 'customerName', + label: 'نام مشتری', + description: 'نام و نام خانوادگی مشتری', + sampleValue: 'مریم احمدی', + }, + { + key: 'salonName', + label: 'نام سالن', + description: 'نام سالن زیبایی', + sampleValue: 'سالن زیبایی رز', + }, + { + key: 'totalPurchase', + label: 'جمع خرید', + description: 'مجموع خرید مشتری تا کنون (تومان)', + sampleValue: '۱۵,۰۰۰,۰۰۰', + }, + ], + }, + { + key: SmsScenarioKey.CAMPAIGN_INACTIVE, + label: 'کمپین مشتریان غیرفعال', + description: + 'کمپینی که سالن به‌صورت دستی برای مشتریانی که بیش از ۶۰ روز مراجعه نکرده‌اند ارسال می‌کند', + defaultBody: + '{customerName} عزیز، مدت زیادی است در {salonName} شما را نمی‌بینیم. دلمان برایتان تنگ شده، منتظر بازگشت شما هستیم 🎁', + variables: [ + { + key: 'customerName', + label: 'نام مشتری', + description: 'نام و نام خانوادگی مشتری', + sampleValue: 'مریم احمدی', + }, + { + key: 'salonName', + label: 'نام سالن', + description: 'نام سالن زیبایی', + sampleValue: 'سالن زیبایی رز', + }, + { + key: 'daysSinceVisit', + label: 'روزهای از آخرین مراجعه', + description: 'تعداد روزهایی که از آخرین مراجعه مشتری گذشته است', + sampleValue: '۹۰', + }, + ], + }, + { + key: SmsScenarioKey.CAMPAIGN_COMPLEMENTARY, + label: 'کمپین خدمت مکمل', + description: + 'کمپینی که سالن به‌صورت دستی برای پیشنهاد خدمات مکمل بر اساس خدمات قبلی مشتری ارسال می‌کند', + defaultBody: + '{customerName} عزیز، متناسب با خدمتی که دریافت کردید، «{suggestedServiceName}» را در {salonName} به شما پیشنهاد می‌کنیم.', + variables: [ + { + key: 'customerName', + label: 'نام مشتری', + description: 'نام و نام خانوادگی مشتری', + sampleValue: 'مریم احمدی', + }, + { + key: 'salonName', + label: 'نام سالن', + description: 'نام سالن زیبایی', + sampleValue: 'سالن زیبایی رز', + }, + { + key: 'suggestedServiceName', + label: 'خدمت پیشنهادی', + description: 'نام خدمت مکمل پیشنهادی', + sampleValue: 'کراتین', + }, + ], + }, +]; + +export const SMS_SCENARIO_MAP = new Map( + SMS_SCENARIOS.map((scenario) => [scenario.key, scenario]), +); + +export function getSampleVariables( + key: SmsScenarioKey, +): Record { + const scenario = SMS_SCENARIO_MAP.get(key); + if (!scenario) { + return {}; + } + + return Object.fromEntries( + scenario.variables.map((variable) => [variable.key, variable.sampleValue]), + ); +} diff --git a/src/sms-settings/dto/update-sms-scenario-setting.dto.ts b/src/sms-settings/dto/update-sms-scenario-setting.dto.ts new file mode 100644 index 0000000..f98dc1e --- /dev/null +++ b/src/sms-settings/dto/update-sms-scenario-setting.dto.ts @@ -0,0 +1,41 @@ +import { + IsBoolean, + IsEnum, + IsOptional, + IsString, + IsUUID, + MaxLength, + ValidateIf, +} from 'class-validator'; +import { SmsScenarioKey } from '../enums/sms-scenario-key.enum'; + +export class UpdateSmsScenarioSettingDto { + @IsOptional() + @ValidateIf((dto: UpdateSmsScenarioSettingDto) => dto.templateId == null) + @IsString() + @MaxLength(1000) + body?: string | null; + + @IsOptional() + @ValidateIf((dto: UpdateSmsScenarioSettingDto) => !dto.body) + @IsUUID() + templateId?: string | null; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class PreviewSmsScenarioDto { + @IsEnum(SmsScenarioKey) + key: SmsScenarioKey; + + @IsOptional() + @IsString() + @MaxLength(1000) + body?: string; + + @IsOptional() + @IsUUID() + templateId?: string | null; +} diff --git a/src/sms-settings/entities/sms-scenario-setting.entity.ts b/src/sms-settings/entities/sms-scenario-setting.entity.ts new file mode 100644 index 0000000..7b1b100 --- /dev/null +++ b/src/sms-settings/entities/sms-scenario-setting.entity.ts @@ -0,0 +1,45 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { SmsTemplate } from '../../sms-templates/entities/sms-template.entity'; +import { SmsScenarioKey } from '../enums/sms-scenario-key.enum'; + +@Entity('sms_scenario_settings') +@Index(['key'], { unique: true }) +export class SmsScenarioSetting { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ + type: 'enum', + enum: SmsScenarioKey, + enumName: 'sms_scenario_key_enum', + }) + key: SmsScenarioKey; + + @Column({ type: 'text', nullable: true }) + body: string | null; + + @Column({ type: 'uuid', nullable: true }) + templateId: string | null; + + @ManyToOne(() => SmsTemplate, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'templateId' }) + template: SmsTemplate | null; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/sms-settings/enums/sms-scenario-key.enum.ts b/src/sms-settings/enums/sms-scenario-key.enum.ts new file mode 100644 index 0000000..c651b58 --- /dev/null +++ b/src/sms-settings/enums/sms-scenario-key.enum.ts @@ -0,0 +1,13 @@ +export enum SmsScenarioKey { + OTP_LOGIN = 'otp_login', + RESERVE_CREATED = 'reserve_created', + RESERVE_REMINDER = 'reserve_reminder', + RESERVE_CONFIRMED = 'reserve_confirmed', + RESERVE_CANCELLED = 'reserve_cancelled', + CAMPAIGN_RETURNING_CUSTOMERS = 'campaign_returning_customers', + CAMPAIGN_BIRTHDAY = 'campaign_birthday', + CAMPAIGN_AT_RISK = 'campaign_at_risk', + CAMPAIGN_VIP = 'campaign_vip', + CAMPAIGN_INACTIVE = 'campaign_inactive', + CAMPAIGN_COMPLEMENTARY = 'campaign_complementary', +} diff --git a/src/sms-settings/reserve-sms.service.ts b/src/sms-settings/reserve-sms.service.ts new file mode 100644 index 0000000..edb228b --- /dev/null +++ b/src/sms-settings/reserve-sms.service.ts @@ -0,0 +1,114 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Reserve } from '../reserves/entities/reserve.entity'; +import { ReserveStatus } from '../reserves/enums/reserve-status.enum'; +import { SmsScenarioKey } from './enums/sms-scenario-key.enum'; +import { SmsDispatchService } from './sms-dispatch.service'; +import { buildReserveSmsVariables } from './utils/build-reserve-sms-variables.util'; + +@Injectable() +export class ReserveSmsService { + private readonly logger = new Logger(ReserveSmsService.name); + + constructor( + private readonly smsDispatchService: SmsDispatchService, + @InjectRepository(Reserve) + private readonly reservesRepository: Repository, + ) {} + + async sendReserveCreated(reserveId: string): Promise { + await this.sendForReserve(reserveId, SmsScenarioKey.RESERVE_CREATED); + } + + async sendReserveConfirmed(reserveId: string): Promise { + await this.sendForReserve(reserveId, SmsScenarioKey.RESERVE_CONFIRMED); + } + + async sendReserveCancelled(reserveId: string): Promise { + await this.sendForReserve(reserveId, SmsScenarioKey.RESERVE_CANCELLED); + } + + async sendReserveReminder(reserveId: string): Promise { + const reserve = await this.loadReserve(reserveId); + if (!reserve) { + return false; + } + + return this.sendForLoadedReserve(reserve, SmsScenarioKey.RESERVE_REMINDER); + } + + /** + * Sends same-day reminder SMS every morning for reserves scheduled for today. + */ + @Cron(CronExpression.EVERY_DAY_AT_8AM, { name: 'reserve-reminders' }) + async processDailyReminders(): Promise { + const today = this.getTodayDateString(); + const reserves = await this.reservesRepository.find({ + where: { + appointmentDate: today, + status: ReserveStatus.CONFIRMED, + }, + relations: { + salon: true, + customer: { user: true }, + stylist: { user: true }, + }, + }); + + let sentCount = 0; + for (const reserve of reserves) { + const sent = await this.sendForLoadedReserve( + reserve, + SmsScenarioKey.RESERVE_REMINDER, + ); + if (sent) { + sentCount += 1; + } + } + + this.logger.log(`Sent ${sentCount} reserve reminder SMS for ${today}`); + return sentCount; + } + + private async sendForReserve( + reserveId: string, + key: SmsScenarioKey, + ): Promise { + const reserve = await this.loadReserve(reserveId); + if (!reserve) { + return false; + } + + return this.sendForLoadedReserve(reserve, key); + } + + private async sendForLoadedReserve( + reserve: Reserve, + key: SmsScenarioKey, + ): Promise { + const mobile = reserve.customer?.user?.mobile; + if (!mobile) { + return false; + } + + const variables = buildReserveSmsVariables(reserve); + return this.smsDispatchService.sendScenarioSms(key, mobile, variables); + } + + private async loadReserve(reserveId: string): Promise { + return this.reservesRepository.findOne({ + where: { id: reserveId }, + relations: { + salon: true, + customer: { user: true }, + stylist: { user: true }, + }, + }); + } + + private getTodayDateString(): string { + return new Date().toISOString().slice(0, 10); + } +} diff --git a/src/sms-settings/sms-dispatch.service.ts b/src/sms-settings/sms-dispatch.service.ts new file mode 100644 index 0000000..89d5b80 --- /dev/null +++ b/src/sms-settings/sms-dispatch.service.ts @@ -0,0 +1,125 @@ +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 { SmsTemplatesService } from '../sms-templates/sms-templates.service'; +import { SmsService } from '../sms/sms.service'; +import { + getSampleVariables, + SMS_SCENARIOS, + SMS_SCENARIO_MAP, +} from './constants/sms-scenarios.constants'; +import { PreviewSmsScenarioDto } from './dto/update-sms-scenario-setting.dto'; +import { SmsScenarioSetting } from './entities/sms-scenario-setting.entity'; +import { SmsScenarioKey } from './enums/sms-scenario-key.enum'; + +@Injectable() +export class SmsDispatchService { + constructor( + @InjectRepository(SmsScenarioSetting) + private readonly settingsRepository: Repository, + private readonly smsTemplatesService: SmsTemplatesService, + private readonly smsService: SmsService, + ) {} + + getScenarioDefinitions() { + return SMS_SCENARIOS; + } + + async findAllSettings(): Promise { + return this.settingsRepository.find({ + relations: { template: true }, + order: { key: 'ASC' }, + }); + } + + async findSettingByKey(key: SmsScenarioKey): Promise { + const setting = await this.settingsRepository.findOne({ + where: { key }, + relations: { template: true }, + }); + + if (!setting) { + throw new NotFoundException(`SMS scenario setting "${key}" not found`); + } + + return setting; + } + + async resolveMessage( + key: SmsScenarioKey, + variables: Record, + ): Promise { + const setting = await this.findSettingByKey(key); + if (!setting.isActive) { + return null; + } + + const body = await this.resolveBody(setting); + if (!body) { + return null; + } + + return renderSmsTemplate(body, variables); + } + + async previewMessage(dto: PreviewSmsScenarioDto): Promise { + const body = await this.resolvePreviewBody(dto); + const variables = getSampleVariables(dto.key); + return renderSmsTemplate(body, variables); + } + + async sendScenarioSms( + key: SmsScenarioKey, + mobile: string, + variables: Record, + ): Promise { + const message = await this.resolveMessage(key, variables); + if (!message) { + return false; + } + + return this.smsService.sendSms(mobile, message); + } + + private async resolveBody( + setting: SmsScenarioSetting, + ): Promise { + if (setting.templateId) { + const template = await this.smsTemplatesService.findOne( + setting.templateId, + ); + if (!template.isActive) { + return null; + } + return template.body; + } + + if (setting.body) { + return setting.body; + } + + return SMS_SCENARIO_MAP.get(setting.key)?.defaultBody ?? null; + } + + private async resolvePreviewBody( + dto: PreviewSmsScenarioDto, + ): Promise { + if (dto.templateId) { + const template = await this.smsTemplatesService.findOne(dto.templateId); + return template.body; + } + + if (dto.body) { + return dto.body; + } + + const setting = await this.findSettingByKey(dto.key); + const resolved = await this.resolveBody(setting); + if (!resolved) { + throw new NotFoundException('No SMS body configured for this scenario'); + } + + return resolved; + } +} diff --git a/src/sms-settings/sms-settings.controller.ts b/src/sms-settings/sms-settings.controller.ts new file mode 100644 index 0000000..022a17a --- /dev/null +++ b/src/sms-settings/sms-settings.controller.ts @@ -0,0 +1,87 @@ +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 { + PreviewSmsScenarioDto, + UpdateSmsScenarioSettingDto, +} from './dto/update-sms-scenario-setting.dto'; +import { SmsScenarioKey } from './enums/sms-scenario-key.enum'; +import { ReserveSmsService } from './reserve-sms.service'; +import { SmsSettingsService } from './sms-settings.service'; + +@ApiTags('SMS Settings') +@ApiBearerAuth(SWAGGER_JWT_AUTH) +@ApiStandardController() +@Controller('sms-settings') +@UseGuards(RolesGuard, PermissionsGuard) +export class SmsSettingsController { + constructor( + private readonly smsSettingsService: SmsSettingsService, + private readonly reserveSmsService: ReserveSmsService, + ) {} + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.SMS_TEMPLATES_READ) + @ApiStandardOkResponse(Object, { isArray: true }) + @Get('scenarios') + getScenarios() { + return this.smsSettingsService.getScenarioDefinitions(); + } + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.SMS_TEMPLATES_READ) + @ApiStandardOkResponse(Object, { isArray: true }) + @Get() + findAll(@CurrentUser() user: AuthUser) { + return this.smsSettingsService.findAll(user); + } + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.SMS_TEMPLATES_WRITE) + @ApiStandardOkResponse(Object) + @Patch(':key') + update( + @Param('key') key: SmsScenarioKey, + @Body() updateDto: UpdateSmsScenarioSettingDto, + @CurrentUser() user: AuthUser, + ) { + return this.smsSettingsService.update(key, updateDto, user); + } + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.SMS_TEMPLATES_READ) + @ApiStandardOkResponse(Object) + @Post('preview') + preview(@Body() dto: PreviewSmsScenarioDto, @CurrentUser() user: AuthUser) { + return this.smsSettingsService.preview(dto, user); + } + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.SMS_TEMPLATES_WRITE) + @ApiStandardOkResponse(Object) + @Post('trigger-reminders') + async triggerReminders(@CurrentUser() user: AuthUser) { + const sentCount = await this.reserveSmsService.processDailyReminders(); + return { sentCount }; + } +} diff --git a/src/sms-settings/sms-settings.module.ts b/src/sms-settings/sms-settings.module.ts new file mode 100644 index 0000000..beb0b05 --- /dev/null +++ b/src/sms-settings/sms-settings.module.ts @@ -0,0 +1,26 @@ +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 { SmsModule } from '../sms/sms.module'; +import { SmsTemplatesModule } from '../sms-templates/sms-templates.module'; +import { SmsScenarioSetting } from './entities/sms-scenario-setting.entity'; +import { ReserveSmsService } from './reserve-sms.service'; +import { SmsDispatchService } from './sms-dispatch.service'; +import { SmsSettingsController } from './sms-settings.controller'; +import { SmsSettingsService } from './sms-settings.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([SmsScenarioSetting, Reserve]), + SmsModule, + SmsTemplatesModule, + AuthorizationModule, + AuthGuardsModule, + ], + controllers: [SmsSettingsController], + providers: [SmsSettingsService, SmsDispatchService, ReserveSmsService], + exports: [SmsDispatchService, ReserveSmsService], +}) +export class SmsSettingsModule {} diff --git a/src/sms-settings/sms-settings.service.ts b/src/sms-settings/sms-settings.service.ts new file mode 100644 index 0000000..c44b0f8 --- /dev/null +++ b/src/sms-settings/sms-settings.service.ts @@ -0,0 +1,144 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ServicePolicy } from '../authorization/policies/service.policy'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { SmsTemplatesService } from '../sms-templates/sms-templates.service'; +import { + SMS_SCENARIO_MAP, + SmsScenarioDefinition, +} from './constants/sms-scenarios.constants'; +import { + PreviewSmsScenarioDto, + UpdateSmsScenarioSettingDto, +} from './dto/update-sms-scenario-setting.dto'; +import { SmsScenarioSetting } from './entities/sms-scenario-setting.entity'; +import { SmsScenarioKey } from './enums/sms-scenario-key.enum'; +import { SmsDispatchService } from './sms-dispatch.service'; + +export interface SmsScenarioSettingResponse { + key: SmsScenarioKey; + body: string | null; + templateId: string | null; + templateTitle: string | null; + isActive: boolean; + definition: SmsScenarioDefinition; + preview: string; +} + +@Injectable() +export class SmsSettingsService { + constructor( + @InjectRepository(SmsScenarioSetting) + private readonly settingsRepository: Repository, + private readonly smsDispatchService: SmsDispatchService, + private readonly smsTemplatesService: SmsTemplatesService, + private readonly servicePolicy: ServicePolicy, + ) {} + + getScenarioDefinitions(): SmsScenarioDefinition[] { + return this.smsDispatchService.getScenarioDefinitions(); + } + + async findAll(user: AuthUser): Promise { + this.ensureCanManage(user); + const settings = await this.smsDispatchService.findAllSettings(); + + return Promise.all(settings.map((setting) => this.toResponse(setting))); + } + + async update( + key: SmsScenarioKey, + updateDto: UpdateSmsScenarioSettingDto, + user: AuthUser, + ): Promise { + this.ensureCanManage(user); + + if (!SMS_SCENARIO_MAP.has(key)) { + throw new NotFoundException(`SMS scenario "${key}" not found`); + } + + const setting = await this.smsDispatchService.findSettingByKey(key); + + if (updateDto.templateId) { + await this.smsTemplatesService.findOne(updateDto.templateId); + setting.templateId = updateDto.templateId; + setting.body = null; + } else if (updateDto.templateId === null) { + setting.templateId = null; + } + + if (updateDto.body !== undefined) { + setting.body = updateDto.body; + if (updateDto.body) { + setting.templateId = null; + } + } + + if (updateDto.isActive !== undefined) { + setting.isActive = updateDto.isActive; + } + + if (!setting.templateId && !setting.body) { + throw new BadRequestException( + 'Either body text or a template must be configured', + ); + } + + const saved = await this.settingsRepository.save(setting); + return this.toResponse(saved); + } + + async preview( + dto: PreviewSmsScenarioDto, + user: AuthUser, + ): Promise<{ preview: string }> { + this.ensureCanManage(user); + const preview = await this.smsDispatchService.previewMessage(dto); + return { preview }; + } + + private async toResponse( + setting: SmsScenarioSetting, + ): Promise { + const definition = SMS_SCENARIO_MAP.get(setting.key); + if (!definition) { + throw new NotFoundException(`SMS scenario "${setting.key}" not found`); + } + + let templateTitle: string | null = null; + if (setting.templateId) { + const template = await this.smsTemplatesService.findOne( + setting.templateId, + ); + templateTitle = template.title; + } + + const preview = await this.smsDispatchService.previewMessage({ + key: setting.key, + body: setting.body ?? undefined, + templateId: setting.templateId, + }); + + return { + key: setting.key, + body: setting.body, + templateId: setting.templateId, + templateTitle, + isActive: setting.isActive, + definition, + preview, + }; + } + + private ensureCanManage(user: AuthUser): void { + if (!this.servicePolicy.canManage(user)) { + throw new ForbiddenException(); + } + } +} diff --git a/src/sms-settings/utils/build-reserve-sms-variables.util.ts b/src/sms-settings/utils/build-reserve-sms-variables.util.ts new file mode 100644 index 0000000..d23438a --- /dev/null +++ b/src/sms-settings/utils/build-reserve-sms-variables.util.ts @@ -0,0 +1,44 @@ +import { Reserve } from '../../reserves/entities/reserve.entity'; + +function formatPersonName(firstName?: string, lastName?: string): string { + return [firstName, lastName].filter(Boolean).join(' ').trim() || 'مشتری'; +} + +function formatTime(time: string): string { + return time.slice(0, 5); +} + +function formatAmount(amount: number): string { + return amount.toLocaleString('fa-IR'); +} + +function formatDate(date: string): string { + const parsed = new Date(`${date}T00:00:00`); + if (Number.isNaN(parsed.getTime())) { + return date; + } + + return parsed.toLocaleDateString('fa-IR'); +} + +export function buildReserveSmsVariables( + reserve: Reserve, +): Record { + return { + customerName: formatPersonName( + reserve.customer?.user?.firstName, + reserve.customer?.user?.lastName, + ), + salonName: reserve.salon?.name ?? '', + stylistName: formatPersonName( + reserve.stylist?.user?.firstName, + reserve.stylist?.user?.lastName, + ), + appointmentDate: formatDate(reserve.appointmentDate), + startTime: formatTime(reserve.startTime), + endTime: formatTime(reserve.endTime), + totalAmount: formatAmount(reserve.totalAmount), + depositAmount: formatAmount(reserve.depositAmount), + cancellationReason: reserve.cancellationReason ?? '—', + }; +}