sms-setting and campain

This commit is contained in:
2026-07-09 19:12:23 +03:30
parent 1dd2813a01
commit ffb95149cf
33 changed files with 2745 additions and 6 deletions
+46
View File
@@ -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",
+1
View File
@@ -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",
+6
View File
@@ -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,
+2 -2
View File
@@ -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,
+11 -3
View File
@@ -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);
}
}
+369
View File
@@ -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,
};
}
}
+120
View File
@@ -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 };
}
}
+43
View File
@@ -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 {}
+357
View File
@@ -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;
}
+15
View File
@@ -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'],
}),
);
}
}
+2
View File
@@ -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],
+19 -1
View File
@@ -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',
}
+114
View File
@@ -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);
}
}
+125
View File
@@ -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 };
}
}
+26
View File
@@ -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 {}
+144
View File
@@ -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 ?? '—',
};
}