sms-setting and campain
This commit is contained in:
Generated
+46
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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<SalonCustomer>,
|
||||
@InjectRepository(Salon)
|
||||
private readonly salonsRepository: Repository<Salon>,
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<ReturnType<CampaignTargetingService['getTargetsForType']>>,
|
||||
): Promise<boolean> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<string>;
|
||||
}
|
||||
|
||||
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<string, string>;
|
||||
}
|
||||
|
||||
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<SalonCustomer>,
|
||||
@InjectRepository(Reserve)
|
||||
private readonly reservesRepository: Repository<Reserve>,
|
||||
@InjectRepository(ServiceSuggestion)
|
||||
private readonly suggestionsRepository: Repository<ServiceSuggestion>,
|
||||
) {}
|
||||
|
||||
async getSalonCustomerContexts(
|
||||
salonId: string,
|
||||
): Promise<SalonCustomerContext[]> {
|
||||
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<string, Reserve[]>();
|
||||
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<CampaignTargetCustomer[]> {
|
||||
const contexts = await this.getSalonCustomerContexts(salonId);
|
||||
return this.filterContextsForType(type, contexts);
|
||||
}
|
||||
|
||||
async filterContextsForType(
|
||||
type: CampaignType,
|
||||
contexts: SalonCustomerContext[],
|
||||
): Promise<CampaignTargetCustomer[]> {
|
||||
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<CampaignTargetCustomer[]> {
|
||||
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<string, ServiceSuggestion[]>();
|
||||
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<string, string>,
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<Salon>,
|
||||
@InjectRepository(CampaignRun)
|
||||
private readonly campaignRunsRepository: Repository<CampaignRun>,
|
||||
@InjectRepository(SentMessage)
|
||||
private readonly sentMessagesRepository: Repository<SentMessage>,
|
||||
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<CampaignOverviewItem[]> {
|
||||
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<CampaignTargetCustomer[]> {
|
||||
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<CampaignRun> {
|
||||
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<CampaignRun> {
|
||||
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<CampaignRun> {
|
||||
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<CampaignRun[]> {
|
||||
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<CampaignRun> {
|
||||
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<SentMessage[]> {
|
||||
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<void> {
|
||||
let sentCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const target of targets) {
|
||||
const variables: Record<string, string> = {
|
||||
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<Salon> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum CampaignRunStatus {
|
||||
COMPLETED = 'completed',
|
||||
PARTIAL = 'partial',
|
||||
FAILED = 'failed',
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum CampaignTrigger {
|
||||
AUTOMATIC = 'automatic',
|
||||
MANUAL = 'manual',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.dropTable('sms_scenario_settings', true);
|
||||
await queryRunner.query('DROP TYPE "sms_scenario_key_enum"');
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, string> {
|
||||
const scenario = SMS_SCENARIO_MAP.get(key);
|
||||
if (!scenario) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
scenario.variables.map((variable) => [variable.key, variable.sampleValue]),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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<Reserve>,
|
||||
) {}
|
||||
|
||||
async sendReserveCreated(reserveId: string): Promise<void> {
|
||||
await this.sendForReserve(reserveId, SmsScenarioKey.RESERVE_CREATED);
|
||||
}
|
||||
|
||||
async sendReserveConfirmed(reserveId: string): Promise<void> {
|
||||
await this.sendForReserve(reserveId, SmsScenarioKey.RESERVE_CONFIRMED);
|
||||
}
|
||||
|
||||
async sendReserveCancelled(reserveId: string): Promise<void> {
|
||||
await this.sendForReserve(reserveId, SmsScenarioKey.RESERVE_CANCELLED);
|
||||
}
|
||||
|
||||
async sendReserveReminder(reserveId: string): Promise<boolean> {
|
||||
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<number> {
|
||||
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<boolean> {
|
||||
const reserve = await this.loadReserve(reserveId);
|
||||
if (!reserve) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.sendForLoadedReserve(reserve, key);
|
||||
}
|
||||
|
||||
private async sendForLoadedReserve(
|
||||
reserve: Reserve,
|
||||
key: SmsScenarioKey,
|
||||
): Promise<boolean> {
|
||||
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<Reserve | null> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<SmsScenarioSetting>,
|
||||
private readonly smsTemplatesService: SmsTemplatesService,
|
||||
private readonly smsService: SmsService,
|
||||
) {}
|
||||
|
||||
getScenarioDefinitions() {
|
||||
return SMS_SCENARIOS;
|
||||
}
|
||||
|
||||
async findAllSettings(): Promise<SmsScenarioSetting[]> {
|
||||
return this.settingsRepository.find({
|
||||
relations: { template: true },
|
||||
order: { key: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findSettingByKey(key: SmsScenarioKey): Promise<SmsScenarioSetting> {
|
||||
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<string, string>,
|
||||
): Promise<string | null> {
|
||||
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<string> {
|
||||
const body = await this.resolvePreviewBody(dto);
|
||||
const variables = getSampleVariables(dto.key);
|
||||
return renderSmsTemplate(body, variables);
|
||||
}
|
||||
|
||||
async sendScenarioSms(
|
||||
key: SmsScenarioKey,
|
||||
mobile: string,
|
||||
variables: Record<string, string>,
|
||||
): Promise<boolean> {
|
||||
const message = await this.resolveMessage(key, variables);
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.smsService.sendSms(mobile, message);
|
||||
}
|
||||
|
||||
private async resolveBody(
|
||||
setting: SmsScenarioSetting,
|
||||
): Promise<string | null> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<SmsScenarioSetting>,
|
||||
private readonly smsDispatchService: SmsDispatchService,
|
||||
private readonly smsTemplatesService: SmsTemplatesService,
|
||||
private readonly servicePolicy: ServicePolicy,
|
||||
) {}
|
||||
|
||||
getScenarioDefinitions(): SmsScenarioDefinition[] {
|
||||
return this.smsDispatchService.getScenarioDefinitions();
|
||||
}
|
||||
|
||||
async findAll(user: AuthUser): Promise<SmsScenarioSettingResponse[]> {
|
||||
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<SmsScenarioSettingResponse> {
|
||||
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<SmsScenarioSettingResponse> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
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 ?? '—',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user