diff --git a/src/campaigns/campaign-automation.service.ts b/src/campaigns/campaign-automation.service.ts index 2d23ee0..375286f 100644 --- a/src/campaigns/campaign-automation.service.ts +++ b/src/campaigns/campaign-automation.service.ts @@ -9,7 +9,6 @@ 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'; -import { getCampaignTypeDefinition } from './constants/campaign-types.constants'; @Injectable() export class CampaignAutomationService { @@ -115,9 +114,16 @@ export class CampaignAutomationService { } try { - const definition = getCampaignTypeDefinition(type); + const defaultTemplate = + await this.campaignsService.getDefaultTemplateForType(type); + if (!defaultTemplate) { + throw new Error('No active template configured for this campaign'); + } + const smsParts = await this.campaignsService.calculateCampaignSmsParts( - definition.scenarioKey, + defaultTemplate.templateId, + defaultTemplate.template.body, + { discountPercent: null, discountAmount: null }, salon, targets, ); @@ -142,6 +148,9 @@ export class CampaignAutomationService { trigger: CampaignTrigger.AUTOMATIC, targets, triggeredByUserId: null, + templateId: ( + await this.campaignsService.getDefaultTemplateForType(type) + )?.templateId, }); return true; diff --git a/src/campaigns/campaign-type-templates.service.ts b/src/campaigns/campaign-type-templates.service.ts new file mode 100644 index 0000000..f0775a2 --- /dev/null +++ b/src/campaigns/campaign-type-templates.service.ts @@ -0,0 +1,167 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SmsTemplatesService } from '../sms-templates/sms-templates.service'; +import { getCampaignTypeDefinition } from './constants/campaign-types.constants'; +import { AssignCampaignTemplateDto } from './dto/assign-campaign-template.dto'; +import { CampaignTypeTemplate } from './entities/campaign-type-template.entity'; +import { CampaignType } from './enums/campaign-type.enum'; + +@Injectable() +export class CampaignTypeTemplatesService { + constructor( + @InjectRepository(CampaignTypeTemplate) + private readonly assignmentsRepository: Repository, + private readonly smsTemplatesService: SmsTemplatesService, + ) {} + + async findAllGrouped(): Promise { + return this.assignmentsRepository.find({ + relations: { template: true }, + order: { campaignType: 'ASC', sortOrder: 'ASC', createdAt: 'ASC' }, + }); + } + + async findByCampaignType( + campaignType: CampaignType, + ): Promise { + getCampaignTypeDefinition(campaignType); + + return this.assignmentsRepository.find({ + where: { campaignType }, + relations: { template: true }, + order: { sortOrder: 'ASC', createdAt: 'ASC' }, + }); + } + + async findActiveByCampaignType( + campaignType: CampaignType, + ): Promise { + const assignments = await this.findByCampaignType(campaignType); + return assignments.filter((assignment) => assignment.template.isActive); + } + + async findDefaultForCampaignType( + campaignType: CampaignType, + ): Promise { + const active = await this.findActiveByCampaignType(campaignType); + return ( + active.find((assignment) => assignment.isDefault) ?? active[0] ?? null + ); + } + + async resolveAssignment( + campaignType: CampaignType, + templateId?: string | null, + ): Promise { + if (templateId) { + const assignment = await this.assignmentsRepository.findOne({ + where: { campaignType, templateId }, + relations: { template: true }, + }); + + if (!assignment) { + throw new BadRequestException( + 'قالب انتخاب‌شده برای این کمپین معتبر نیست', + ); + } + + if (!assignment.template.isActive) { + throw new BadRequestException('قالب انتخاب‌شده غیرفعال است'); + } + + return assignment; + } + + const fallback = await this.findDefaultForCampaignType(campaignType); + if (!fallback) { + throw new BadRequestException( + 'هیچ قالب فعالی برای این کمپین تنظیم نشده است', + ); + } + + return fallback; + } + + async assignTemplate( + dto: AssignCampaignTemplateDto, + ): Promise { + getCampaignTypeDefinition(dto.campaignType); + await this.smsTemplatesService.findOne(dto.templateId); + + const existing = await this.assignmentsRepository.findOne({ + where: { + campaignType: dto.campaignType, + templateId: dto.templateId, + }, + }); + if (existing) { + throw new BadRequestException('این قالب قبلاً به این کمپین اضافه شده است'); + } + + const count = await this.assignmentsRepository.count({ + where: { campaignType: dto.campaignType }, + }); + + const assignment = await this.assignmentsRepository.save( + this.assignmentsRepository.create({ + campaignType: dto.campaignType, + templateId: dto.templateId, + isDefault: count === 0, + sortOrder: count, + }), + ); + + return this.findOne(assignment.id); + } + + async setDefault(id: string): Promise { + const assignment = await this.findOne(id); + + await this.assignmentsRepository.update( + { campaignType: assignment.campaignType }, + { isDefault: false }, + ); + + assignment.isDefault = true; + await this.assignmentsRepository.save(assignment); + + return this.findOne(id); + } + + async remove(id: string): Promise { + const assignment = await this.findOne(id); + const wasDefault = assignment.isDefault; + + await this.assignmentsRepository.remove(assignment); + + if (wasDefault) { + const next = await this.assignmentsRepository.findOne({ + where: { campaignType: assignment.campaignType }, + order: { sortOrder: 'ASC', createdAt: 'ASC' }, + }); + + if (next) { + next.isDefault = true; + await this.assignmentsRepository.save(next); + } + } + } + + async findOne(id: string): Promise { + const assignment = await this.assignmentsRepository.findOne({ + where: { id }, + relations: { template: true }, + }); + + if (!assignment) { + throw new NotFoundException(`Campaign template assignment #${id} not found`); + } + + return assignment; + } +} diff --git a/src/campaigns/campaigns.controller.ts b/src/campaigns/campaigns.controller.ts index 618b279..91644f3 100644 --- a/src/campaigns/campaigns.controller.ts +++ b/src/campaigns/campaigns.controller.ts @@ -1,9 +1,11 @@ import { Body, Controller, + Delete, Get, Param, ParseUUIDPipe, + Patch, Post, Query, UseGuards, @@ -18,15 +20,20 @@ import { AuthUser } from '../auth/interfaces/auth-user.interface'; import { PermissionCode } from '../authorization/enums/permission-code.enum'; import { ApiStandardController, + ApiStandardEmptyResponse, 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 { CampaignTypeTemplatesService } from './campaign-type-templates.service'; import { CampaignsService } from './campaigns.service'; +import { AssignCampaignTemplateDto } from './dto/assign-campaign-template.dto'; import { FindCampaignRunsQueryDto } from './dto/find-campaign-runs-query.dto'; +import { PreviewCampaignTemplateQueryDto } from './dto/preview-campaign-template.dto'; import { SendCampaignDto } from './dto/send-campaign.dto'; import { CampaignRun } from './entities/campaign-run.entity'; +import { CampaignTypeTemplate } from './entities/campaign-type-template.entity'; import { CampaignType } from './enums/campaign-type.enum'; @ApiTags('Campaigns') @@ -38,6 +45,7 @@ export class CampaignsController { constructor( private readonly campaignsService: CampaignsService, private readonly campaignAutomationService: CampaignAutomationService, + private readonly campaignTypeTemplatesService: CampaignTypeTemplatesService, ) {} @Roles(UserRole.SALON) @@ -58,6 +66,38 @@ export class CampaignsController { return this.campaignsService.findRuns(user, query); } + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.CAMPAIGNS_READ) + @ApiStandardOkResponse(CampaignTypeTemplate, { isArray: true }) + @Get('type-templates') + findAllTypeTemplates() { + return this.campaignTypeTemplatesService.findAllGrouped(); + } + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.CAMPAIGNS_WRITE) + @ApiStandardOkResponse(CampaignTypeTemplate) + @Post('type-templates') + assignTypeTemplate(@Body() dto: AssignCampaignTemplateDto) { + return this.campaignTypeTemplatesService.assignTemplate(dto); + } + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.CAMPAIGNS_WRITE) + @ApiStandardOkResponse(CampaignTypeTemplate) + @Patch('type-templates/:id/default') + setDefaultTypeTemplate(@Param('id', ParseUUIDPipe) id: string) { + return this.campaignTypeTemplatesService.setDefault(id); + } + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.CAMPAIGNS_WRITE) + @ApiStandardEmptyResponse() + @Delete('type-templates/:id') + removeTypeTemplate(@Param('id', ParseUUIDPipe) id: string) { + return this.campaignTypeTemplatesService.remove(id); + } + @Roles(UserRole.SALON, UserRole.ADMIN) @Permissions(PermissionCode.CAMPAIGNS_READ) @ApiStandardOkResponse(CampaignRun) @@ -80,6 +120,29 @@ export class CampaignsController { return this.campaignsService.getRunRecipients(id, user); } + @Roles(UserRole.SALON) + @ApiStandardOkResponse(CampaignTypeTemplate, { isArray: true }) + @Get(':type/templates') + getTemplates(@Param('type') type: CampaignType, @CurrentUser() user: AuthUser) { + return this.campaignsService.getTemplates(type, user); + } + + @Roles(UserRole.SALON) + @ApiStandardOkResponse(Object) + @Get(':type/preview') + previewTemplate( + @Param('type') type: CampaignType, + @Query() query: PreviewCampaignTemplateQueryDto, + @CurrentUser() user: AuthUser, + ) { + return this.campaignsService.previewTemplate( + type, + query.templateId, + { discountPercent: query.discountPercent, discountAmount: query.discountAmount }, + user, + ); + } + @Roles(UserRole.SALON) @ApiStandardOkResponse(Object, { isArray: true }) @Get(':type/targets') diff --git a/src/campaigns/campaigns.module.ts b/src/campaigns/campaigns.module.ts index 078dbde..2abdf8a 100644 --- a/src/campaigns/campaigns.module.ts +++ b/src/campaigns/campaigns.module.ts @@ -9,19 +9,23 @@ 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 { SmsTemplatesModule } from '../sms-templates/sms-templates.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 { CampaignTypeTemplatesService } from './campaign-type-templates.service'; import { CampaignsController } from './campaigns.controller'; import { CampaignsService } from './campaigns.service'; import { CampaignRun } from './entities/campaign-run.entity'; +import { CampaignTypeTemplate } from './entities/campaign-type-template.entity'; @Module({ imports: [ TypeOrmModule.forFeature([ Salon, CampaignRun, + CampaignTypeTemplate, SentMessage, SalonCustomer, Reserve, @@ -29,6 +33,7 @@ import { CampaignRun } from './entities/campaign-run.entity'; ]), SmsModule, SmsSettingsModule, + SmsTemplatesModule, NotificationsModule, SubscriptionsModule, AuthorizationModule, @@ -39,7 +44,12 @@ import { CampaignRun } from './entities/campaign-run.entity'; CampaignsService, CampaignTargetingService, CampaignAutomationService, + CampaignTypeTemplatesService, + ], + exports: [ + CampaignsService, + CampaignTargetingService, + CampaignTypeTemplatesService, ], - exports: [CampaignsService, CampaignTargetingService], }) export class CampaignsModule {} diff --git a/src/campaigns/campaigns.service.ts b/src/campaigns/campaigns.service.ts index b6b477e..483374c 100644 --- a/src/campaigns/campaigns.service.ts +++ b/src/campaigns/campaigns.service.ts @@ -21,6 +21,7 @@ import { CampaignTargetCustomer, CampaignTargetingService, } from './campaign-targeting.service'; +import { CampaignTypeTemplatesService } from './campaign-type-templates.service'; import { CAMPAIGN_TYPES, getCampaignTypeDefinition, @@ -28,9 +29,21 @@ import { import { FindCampaignRunsQueryDto } from './dto/find-campaign-runs-query.dto'; import { SendCampaignDto } from './dto/send-campaign.dto'; import { CampaignRun } from './entities/campaign-run.entity'; +import { CampaignTypeTemplate } from './entities/campaign-type-template.entity'; import { CampaignRunStatus } from './enums/campaign-run-status.enum'; import { CampaignTrigger } from './enums/campaign-trigger.enum'; import { CampaignType } from './enums/campaign-type.enum'; +import { getSampleVariables } from '../sms-settings/constants/sms-scenarios.constants'; +import { + buildCampaignMessageVariables, + buildCampaignPreviewVariables, +} from './utils/build-campaign-message-variables.util'; +import { + CampaignDiscountFields, + applyCampaignDiscountFallback, + validateCampaignDiscountFields, + normalizeCampaignDiscountFields, +} from './utils/format-campaign-discount.util'; export interface CampaignOverviewItem { type: CampaignType; @@ -59,6 +72,7 @@ export class CampaignsService { private readonly salonSubscriptionsService: SalonSubscriptionsService, private readonly salonNotificationsService: SalonNotificationsService, private readonly authorizationService: AuthorizationService, + private readonly campaignTypeTemplatesService: CampaignTypeTemplatesService, ) {} async getOverview(user: AuthUser): Promise { @@ -98,6 +112,56 @@ export class CampaignsService { return this.targetingService.getTargetsForType(type, salon.id); } + async getTemplates( + type: CampaignType, + user: AuthUser, + ): Promise { + this.ensureValidType(type); + await this.resolveSalonForUser(user); + return this.campaignTypeTemplatesService.findActiveByCampaignType(type); + } + + /** + * Preview is generated with the discount the salon is currently configuring + * for this send (not stored anywhere) so they see exactly what customers + * will receive. + */ + async previewTemplate( + type: CampaignType, + templateId: string, + discountInput: { discountPercent?: number | null; discountAmount?: number | null }, + user: AuthUser, + ): Promise { + const definition = this.ensureValidType(type); + const salon = await this.resolveSalonForUser(user); + const assignment = await this.campaignTypeTemplatesService.resolveAssignment( + type, + templateId, + ); + const discount = this.resolveDiscountFields(discountInput); + + const variables = buildCampaignPreviewVariables( + salon.name, + getSampleVariables(definition.scenarioKey), + discount, + ); + + const message = await this.smsDispatchService.resolveMessageFromTemplateId( + assignment.templateId, + variables, + ); + + if (!message) { + throw new BadRequestException('امکان پیش‌نمایش این قالب وجود ندارد'); + } + + return applyCampaignDiscountFallback( + message, + assignment.template.body, + discount, + ); + } + async sendCampaign( type: CampaignType, dto: SendCampaignDto, @@ -126,18 +190,28 @@ export class CampaignsService { throw new BadRequestException('مشتری هدفی برای این کمپین یافت نشد'); } - const sampleMessage = await this.smsDispatchService.resolveMessage( - definition.scenarioKey, - {}, + const assignment = await this.campaignTypeTemplatesService.resolveAssignment( + type, + dto.templateId, + ); + const discount = this.resolveDiscountFields(dto); + + const sampleMessage = await this.smsDispatchService.resolveMessageFromTemplateId( + assignment.templateId, + buildCampaignPreviewVariables( + salon.name, + getSampleVariables(definition.scenarioKey), + discount, + ), ); if (!sampleMessage) { - throw new BadRequestException( - 'متن پیامک این کمپین در تنظیمات پیامک ادمین فعال نشده است', - ); + throw new BadRequestException('قالب انتخاب‌شده فعال نیست'); } const smsParts = await this.calculateCampaignSmsParts( - definition.scenarioKey, + assignment.templateId, + assignment.template.body, + discount, salon, targets, ); @@ -149,6 +223,9 @@ export class CampaignsService { trigger: CampaignTrigger.MANUAL, targets, triggeredByUserId: user.id, + templateId: assignment.templateId, + discountPercent: discount.discountPercent, + discountAmount: discount.discountAmount, }); return this.findRun(run.id, user); @@ -158,6 +235,7 @@ export class CampaignsService { * 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. + * Automatic campaigns have no salon input, so they never carry a discount. */ async runCampaignForSalon(params: { salon: Salon; @@ -165,8 +243,15 @@ export class CampaignsService { trigger: CampaignTrigger; targets: CampaignTargetCustomer[]; triggeredByUserId?: string | null; + templateId?: string | null; + discountPercent?: number | null; + discountAmount?: number | null; }): Promise { - const definition = getCampaignTypeDefinition(params.type); + const assignment = await this.campaignTypeTemplatesService.resolveAssignment( + params.type, + params.templateId, + ); + const discount = this.resolveDiscountFields(params); const run = await this.campaignRunsRepository.save( this.campaignRunsRepository.create({ @@ -179,13 +264,18 @@ export class CampaignsService { failedCount: 0, estimatedRevenue: this.sumEstimatedRevenue(params.targets), triggeredByUserId: params.triggeredByUserId ?? null, + templateId: assignment.templateId, + discountPercent: discount.discountPercent, + discountAmount: discount.discountAmount, }), ); await this.dispatchToTargets( run, params.salon, - definition.scenarioKey, + assignment.templateId, + assignment.template.body, + discount, params.targets, ); @@ -228,27 +318,29 @@ export class CampaignsService { } async calculateCampaignSmsParts( - scenarioKey: (typeof CAMPAIGN_TYPES)[number]['scenarioKey'], + templateId: string, + templateBody: string, + discount: CampaignDiscountFields, salon: Salon, targets: CampaignTargetCustomer[], ): Promise { let totalParts = 0; for (const target of targets) { - const variables: Record = { - customerName: - `${target.firstName} ${target.lastName}`.trim() || 'مشتری', - salonName: salon.name, - ...target.meta, - }; + const variables = buildCampaignMessageVariables(salon, target, discount); - const message = await this.smsDispatchService.resolveMessage( - scenarioKey, + const message = await this.smsDispatchService.resolveMessageFromTemplateId( + templateId, variables, ); if (message) { - totalParts += calculateSmsParts(message); + const finalMessage = applyCampaignDiscountFallback( + message, + templateBody, + discount, + ); + totalParts += calculateSmsParts(finalMessage); } } @@ -287,7 +379,7 @@ export class CampaignsService { async findRun(id: string, user: AuthUser): Promise { const run = await this.campaignRunsRepository.findOne({ where: { id }, - relations: { salon: true, triggeredByUser: true }, + relations: { salon: true, triggeredByUser: true, template: true }, }); if (!run) { throw new NotFoundException(`Campaign run #${id} not found`); @@ -305,27 +397,31 @@ export class CampaignsService { }); } + async getDefaultTemplateForType(type: CampaignType) { + return this.campaignTypeTemplatesService.findDefaultForCampaignType(type); + } + private async dispatchToTargets( run: CampaignRun, salon: Salon, - scenarioKey: (typeof CAMPAIGN_TYPES)[number]['scenarioKey'], + templateId: string, + templateBody: string, + discount: CampaignDiscountFields, targets: CampaignTargetCustomer[], ): Promise { let sentCount = 0; let failedCount = 0; for (const target of targets) { - const variables: Record = { - customerName: - `${target.firstName} ${target.lastName}`.trim() || 'مشتری', - salonName: salon.name, - ...target.meta, - }; + const variables = buildCampaignMessageVariables(salon, target, discount); - const message = await this.smsDispatchService.resolveMessage( - scenarioKey, + const rawMessage = await this.smsDispatchService.resolveMessageFromTemplateId( + templateId, variables, ); + const message = rawMessage + ? applyCampaignDiscountFallback(rawMessage, templateBody, discount) + : rawMessage; let status = SentMessageStatus.FAILED; if (message) { @@ -374,6 +470,19 @@ export class CampaignsService { }); } + private resolveDiscountFields(input: { + discountPercent?: number | null; + discountAmount?: number | null; + }): CampaignDiscountFields { + try { + validateCampaignDiscountFields(input); + } catch (error) { + throw new BadRequestException((error as Error).message); + } + + return normalizeCampaignDiscountFields(input); + } + private ensureValidType(type: CampaignType) { try { return getCampaignTypeDefinition(type); diff --git a/src/campaigns/dto/assign-campaign-template.dto.ts b/src/campaigns/dto/assign-campaign-template.dto.ts new file mode 100644 index 0000000..731b4a0 --- /dev/null +++ b/src/campaigns/dto/assign-campaign-template.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsUUID } from 'class-validator'; +import { CampaignType } from '../enums/campaign-type.enum'; + +export class AssignCampaignTemplateDto { + @ApiProperty({ enum: CampaignType }) + @IsEnum(CampaignType) + campaignType: CampaignType; + + @ApiProperty() + @IsUUID('4') + templateId: string; +} diff --git a/src/campaigns/dto/campaign-discount-fields.dto.ts b/src/campaigns/dto/campaign-discount-fields.dto.ts new file mode 100644 index 0000000..81f6f21 --- /dev/null +++ b/src/campaigns/dto/campaign-discount-fields.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsInt, + IsNumber, + IsOptional, + Max, + Min, + ValidateIf, +} from 'class-validator'; + +export class CampaignDiscountFieldsDto { + @ApiPropertyOptional({ description: 'Discount percentage (1–100)' }) + @IsOptional() + @ValidateIf((_, value) => value != null) + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + discountPercent?: number | null; + + @ApiPropertyOptional({ description: 'Fixed discount amount in tomans' }) + @IsOptional() + @ValidateIf((_, value) => value != null) + @Type(() => Number) + @IsNumber() + @Min(1) + discountAmount?: number | null; +} diff --git a/src/campaigns/dto/preview-campaign-template.dto.ts b/src/campaigns/dto/preview-campaign-template.dto.ts new file mode 100644 index 0000000..1535b52 --- /dev/null +++ b/src/campaigns/dto/preview-campaign-template.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; +import { CampaignDiscountFieldsDto } from './campaign-discount-fields.dto'; + +export class PreviewCampaignTemplateQueryDto extends CampaignDiscountFieldsDto { + @ApiProperty() + @IsUUID('4') + templateId: string; +} diff --git a/src/campaigns/dto/send-campaign.dto.ts b/src/campaigns/dto/send-campaign.dto.ts index b3960ce..6ecd26e 100644 --- a/src/campaigns/dto/send-campaign.dto.ts +++ b/src/campaigns/dto/send-campaign.dto.ts @@ -1,7 +1,14 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ArrayMinSize, IsArray, IsOptional, IsUUID } from 'class-validator'; +import { CampaignDiscountFieldsDto } from './campaign-discount-fields.dto'; + +export class SendCampaignDto extends CampaignDiscountFieldsDto { + @ApiProperty({ + description: 'Selected SMS template id for this campaign run.', + }) + @IsUUID('4') + templateId: string; -export class SendCampaignDto { @ApiPropertyOptional({ description: 'Optional subset of currently targeted customer ids to send to. When omitted, all current targets receive the campaign.', diff --git a/src/campaigns/entities/campaign-run.entity.ts b/src/campaigns/entities/campaign-run.entity.ts index b2dc8dd..d0141ea 100644 --- a/src/campaigns/entities/campaign-run.entity.ts +++ b/src/campaigns/entities/campaign-run.entity.ts @@ -9,6 +9,7 @@ import { UpdateDateColumn, } from 'typeorm'; import { Salon } from '../../salons/entities/salon.entity'; +import { SmsTemplate } from '../../sms-templates/entities/sms-template.entity'; import { User } from '../../users/entities/user.entity'; import { CampaignRunStatus } from '../enums/campaign-run-status.enum'; import { CampaignTrigger } from '../enums/campaign-trigger.enum'; @@ -77,6 +78,29 @@ export class CampaignRun { @JoinColumn({ name: 'triggeredByUserId' }) triggeredByUser: User | null; + @Column({ type: 'uuid', nullable: true }) + templateId: string | null; + + @ManyToOne(() => SmsTemplate, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'templateId' }) + template: SmsTemplate | null; + + @Column({ type: 'int', nullable: true }) + discountPercent: number | null; + + @Column({ + type: 'decimal', + precision: 14, + scale: 2, + nullable: true, + transformer: { + to: (value: number | null) => value, + from: (value: string | null) => + value == null ? null : parseFloat(value), + }, + }) + discountAmount: number | null; + @CreateDateColumn() createdAt: Date; diff --git a/src/campaigns/entities/campaign-type-template.entity.ts b/src/campaigns/entities/campaign-type-template.entity.ts new file mode 100644 index 0000000..4b0ed8b --- /dev/null +++ b/src/campaigns/entities/campaign-type-template.entity.ts @@ -0,0 +1,46 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { SmsTemplate } from '../../sms-templates/entities/sms-template.entity'; +import { CampaignType } from '../enums/campaign-type.enum'; + +@Entity('campaign_type_templates') +@Index(['campaignType', 'templateId'], { unique: true }) +@Index(['campaignType', 'sortOrder']) +export class CampaignTypeTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ + type: 'enum', + enum: CampaignType, + enumName: 'campaign_type_enum', + }) + campaignType: CampaignType; + + @Column() + templateId: string; + + @ManyToOne(() => SmsTemplate, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'templateId' }) + template: SmsTemplate; + + @Column({ default: false }) + isDefault: boolean; + + @Column({ type: 'int', default: 0 }) + sortOrder: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/campaigns/utils/build-campaign-message-variables.util.ts b/src/campaigns/utils/build-campaign-message-variables.util.ts new file mode 100644 index 0000000..cf864e5 --- /dev/null +++ b/src/campaigns/utils/build-campaign-message-variables.util.ts @@ -0,0 +1,29 @@ +import { Salon } from '../../salons/entities/salon.entity'; +import { CampaignTargetCustomer } from '../campaign-targeting.service'; +import { CampaignDiscountFields, formatCampaignDiscount } from './format-campaign-discount.util'; + +export function buildCampaignMessageVariables( + salon: Salon, + target: CampaignTargetCustomer, + discount: CampaignDiscountFields, +): Record { + return { + customerName: + `${target.firstName} ${target.lastName}`.trim() || 'مشتری', + salonName: salon.name, + discount: formatCampaignDiscount(discount), + ...target.meta, + }; +} + +export function buildCampaignPreviewVariables( + salonName: string, + sampleVariables: Record, + discount: CampaignDiscountFields, +): Record { + return { + ...sampleVariables, + salonName, + discount: formatCampaignDiscount(discount), + }; +} diff --git a/src/campaigns/utils/format-campaign-discount.util.ts b/src/campaigns/utils/format-campaign-discount.util.ts new file mode 100644 index 0000000..17bc136 --- /dev/null +++ b/src/campaigns/utils/format-campaign-discount.util.ts @@ -0,0 +1,77 @@ +export interface CampaignDiscountFields { + discountPercent: number | null; + discountAmount: number | null; +} + +export function formatCampaignDiscount( + fields: CampaignDiscountFields, +): string { + if (fields.discountPercent != null && fields.discountPercent > 0) { + return `${fields.discountPercent.toLocaleString('fa-IR')}٪ تخفیف`; + } + + if (fields.discountAmount != null && fields.discountAmount > 0) { + return `${Math.round(fields.discountAmount).toLocaleString('fa-IR')} تومان تخفیف`; + } + + return ''; +} + +export function validateCampaignDiscountFields(fields: { + discountPercent?: number | null; + discountAmount?: number | null; +}): void { + const hasPercent = + fields.discountPercent != null && fields.discountPercent > 0; + const hasAmount = fields.discountAmount != null && fields.discountAmount > 0; + + if (hasPercent && hasAmount) { + throw new Error('فقط یک نوع تخفیف می‌تواند تنظیم شود'); + } + + if ( + fields.discountPercent != null && + (fields.discountPercent < 1 || fields.discountPercent > 100) + ) { + throw new Error('درصد تخفیف باید بین ۱ تا ۱۰۰ باشد'); + } + + if (fields.discountAmount != null && fields.discountAmount <= 0) { + throw new Error('مبلغ تخفیف باید بیشتر از صفر باشد'); + } +} + +export function normalizeCampaignDiscountFields(fields: { + discountPercent?: number | null; + discountAmount?: number | null; +}): CampaignDiscountFields { + return { + discountPercent: + fields.discountPercent != null && fields.discountPercent > 0 + ? fields.discountPercent + : null, + discountAmount: + fields.discountAmount != null && fields.discountAmount > 0 + ? fields.discountAmount + : null, + }; +} + +/** + * Templates aren't required to contain a "{discount}" placeholder. If the + * salon set a discount for this send but the chosen template's raw body has + * no placeholder for it, append it to the end of the message so the + * discount the salon configured is never silently dropped. + */ +export function applyCampaignDiscountFallback( + message: string, + templateBody: string, + discount: CampaignDiscountFields, +): string { + const label = formatCampaignDiscount(discount); + if (!label || templateBody.includes('{discount}')) { + return message; + } + + return `${message}\n\n${label}`; +} diff --git a/src/database/migrations/1722300000000-AddCampaignTypeTemplates.ts b/src/database/migrations/1722300000000-AddCampaignTypeTemplates.ts new file mode 100644 index 0000000..ae87f48 --- /dev/null +++ b/src/database/migrations/1722300000000-AddCampaignTypeTemplates.ts @@ -0,0 +1,157 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, +} from 'typeorm'; +import { CAMPAIGN_TYPES } from '../../campaigns/constants/campaign-types.constants'; +import { CampaignType } from '../../campaigns/enums/campaign-type.enum'; +import { SMS_SCENARIOS } from '../../sms-settings/constants/sms-scenarios.constants'; + +export class AddCampaignTypeTemplates1722300000000 + implements MigrationInterface +{ + name = 'AddCampaignTypeTemplates1722300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await this.createCampaignTypeTemplatesTable(queryRunner); + await this.addTemplateIdToCampaignRuns(queryRunner); + await this.seedCampaignTypeTemplates(queryRunner); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "campaign_runs" DROP CONSTRAINT IF EXISTS "FK_campaign_runs_templateId"`, + ); + await queryRunner.query( + `ALTER TABLE "campaign_runs" DROP COLUMN IF EXISTS "templateId"`, + ); + await queryRunner.dropTable('campaign_type_templates', true); + } + + private async createCampaignTypeTemplatesTable( + queryRunner: QueryRunner, + ): Promise { + await queryRunner.createTable( + new Table({ + name: 'campaign_type_templates', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'campaignType', type: 'campaign_type_enum' }, + { name: 'templateId', type: 'uuid' }, + { name: 'isDefault', type: 'boolean', default: false }, + { name: 'sortOrder', type: 'int', default: 0 }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'campaign_type_templates', + new TableForeignKey({ + columnNames: ['templateId'], + referencedTableName: 'sms_templates', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createIndex( + 'campaign_type_templates', + new TableIndex({ + name: 'IDX_campaign_type_templates_type_template', + columnNames: ['campaignType', 'templateId'], + isUnique: true, + }), + ); + + await queryRunner.createIndex( + 'campaign_type_templates', + new TableIndex({ + name: 'IDX_campaign_type_templates_type_sort', + columnNames: ['campaignType', 'sortOrder'], + }), + ); + } + + private async addTemplateIdToCampaignRuns( + queryRunner: QueryRunner, + ): Promise { + await queryRunner.query( + `ALTER TABLE "campaign_runs" ADD COLUMN "templateId" uuid NULL`, + ); + + await queryRunner.createForeignKey( + 'campaign_runs', + new TableForeignKey({ + name: 'FK_campaign_runs_templateId', + columnNames: ['templateId'], + referencedTableName: 'sms_templates', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + } + + private async seedCampaignTypeTemplates( + queryRunner: QueryRunner, + ): Promise { + for (const definition of CAMPAIGN_TYPES) { + const existing = await queryRunner.query( + `SELECT id FROM campaign_type_templates WHERE "campaignType" = $1 LIMIT 1`, + [definition.type], + ); + if (existing.length > 0) { + continue; + } + + const settingRows = await queryRunner.query( + `SELECT body, "templateId" FROM sms_scenario_settings WHERE key = $1 LIMIT 1`, + [definition.scenarioKey], + ); + const setting = settingRows[0] as + | { body: string | null; templateId: string | null } + | undefined; + + let templateId: string | null = setting?.templateId ?? null; + + if (!templateId) { + const scenario = SMS_SCENARIOS.find( + (item) => item.key === definition.scenarioKey, + ); + const body = setting?.body ?? scenario?.defaultBody; + if (!body) { + continue; + } + + const inserted = await queryRunner.query( + ` + INSERT INTO sms_templates (title, body, "isActive") + VALUES ($1, $2, true) + RETURNING id + `, + [`${definition.label} (پیش‌فرض)`, body], + ); + templateId = inserted[0].id as string; + } + + await queryRunner.query( + ` + INSERT INTO campaign_type_templates ("campaignType", "templateId", "isDefault", "sortOrder") + VALUES ($1, $2, true, 0) + ON CONFLICT DO NOTHING + `, + [definition.type, templateId], + ); + } + } +} diff --git a/src/database/migrations/1722400000000-AddCampaignTemplateDiscount.ts b/src/database/migrations/1722400000000-AddCampaignTemplateDiscount.ts new file mode 100644 index 0000000..6142835 --- /dev/null +++ b/src/database/migrations/1722400000000-AddCampaignTemplateDiscount.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Discount is chosen by the salon at send time, not fixed per admin + * template — so these columns live only on campaign_runs, as a snapshot of + * what was actually used for that specific send. + */ +export class AddCampaignTemplateDiscount1722400000000 + implements MigrationInterface +{ + name = 'AddCampaignTemplateDiscount1722400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns('campaign_runs', [ + new TableColumn({ + name: 'discountPercent', + type: 'int', + isNullable: true, + }), + new TableColumn({ + name: 'discountAmount', + type: 'decimal', + precision: 14, + scale: 2, + isNullable: true, + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('campaign_runs', 'discountAmount'); + await queryRunner.dropColumn('campaign_runs', 'discountPercent'); + } +} + diff --git a/src/sms-settings/constants/sms-scenarios.constants.ts b/src/sms-settings/constants/sms-scenarios.constants.ts index c550d94..ef73ca8 100644 --- a/src/sms-settings/constants/sms-scenarios.constants.ts +++ b/src/sms-settings/constants/sms-scenarios.constants.ts @@ -15,6 +15,13 @@ export interface SmsScenarioDefinition { variables: SmsScenarioVariable[]; } +export const CAMPAIGN_DISCOUNT_VARIABLE: SmsScenarioVariable = { + key: 'discount', + label: 'تخفیف', + description: 'متن تخفیف تعریف‌شده برای قالب کمپین (مثلاً ۲۰٪ تخفیف)', + sampleValue: '۲۰٪ تخفیف', +}; + export const SMS_SCENARIOS: SmsScenarioDefinition[] = [ { key: SmsScenarioKey.OTP_LOGIN, @@ -255,6 +262,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [ description: 'تعداد روز باقی‌مانده تا موعد تجدید (۰ یعنی امروز)', sampleValue: '۴', }, + CAMPAIGN_DISCOUNT_VARIABLE, ], }, { @@ -277,6 +285,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [ description: 'نام سالن زیبایی', sampleValue: 'سالن زیبایی رز', }, + CAMPAIGN_DISCOUNT_VARIABLE, ], }, { @@ -305,6 +314,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [ description: 'تعداد روزهایی که از آخرین مراجعه مشتری گذشته است', sampleValue: '۴۰', }, + CAMPAIGN_DISCOUNT_VARIABLE, ], }, { @@ -333,6 +343,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [ description: 'مجموع خرید مشتری تا کنون (تومان)', sampleValue: '۱۵,۰۰۰,۰۰۰', }, + CAMPAIGN_DISCOUNT_VARIABLE, ], }, { @@ -361,6 +372,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [ description: 'تعداد روزهایی که از آخرین مراجعه مشتری گذشته است', sampleValue: '۹۰', }, + CAMPAIGN_DISCOUNT_VARIABLE, ], }, { @@ -389,6 +401,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [ description: 'نام خدمت مکمل پیشنهادی', sampleValue: 'کراتین', }, + CAMPAIGN_DISCOUNT_VARIABLE, ], }, { diff --git a/src/sms-settings/sms-dispatch.service.ts b/src/sms-settings/sms-dispatch.service.ts index 89d5b80..1232531 100644 --- a/src/sms-settings/sms-dispatch.service.ts +++ b/src/sms-settings/sms-dispatch.service.ts @@ -63,6 +63,18 @@ export class SmsDispatchService { return renderSmsTemplate(body, variables); } + async resolveMessageFromTemplateId( + templateId: string, + variables: Record, + ): Promise { + const template = await this.smsTemplatesService.findOne(templateId); + if (!template.isActive) { + return null; + } + + return renderSmsTemplate(template.body, variables); + } + async previewMessage(dto: PreviewSmsScenarioDto): Promise { const body = await this.resolvePreviewBody(dto); const variables = getSampleVariables(dto.key); diff --git a/src/sms-settings/sms-settings.service.ts b/src/sms-settings/sms-settings.service.ts index c44b0f8..8f15dc2 100644 --- a/src/sms-settings/sms-settings.service.ts +++ b/src/sms-settings/sms-settings.service.ts @@ -20,6 +20,7 @@ import { import { SmsScenarioSetting } from './entities/sms-scenario-setting.entity'; import { SmsScenarioKey } from './enums/sms-scenario-key.enum'; import { SmsDispatchService } from './sms-dispatch.service'; +import { isCampaignSmsScenarioKey } from './utils/is-campaign-sms-scenario.util'; export interface SmsScenarioSettingResponse { key: SmsScenarioKey; @@ -42,14 +43,20 @@ export class SmsSettingsService { ) {} getScenarioDefinitions(): SmsScenarioDefinition[] { - return this.smsDispatchService.getScenarioDefinitions(); + return this.smsDispatchService + .getScenarioDefinitions() + .filter((definition) => !isCampaignSmsScenarioKey(definition.key)); } async findAll(user: AuthUser): Promise { this.ensureCanManage(user); const settings = await this.smsDispatchService.findAllSettings(); - return Promise.all(settings.map((setting) => this.toResponse(setting))); + return Promise.all( + settings + .filter((setting) => !isCampaignSmsScenarioKey(setting.key)) + .map((setting) => this.toResponse(setting)), + ); } async update( diff --git a/src/sms-settings/utils/is-campaign-sms-scenario.util.ts b/src/sms-settings/utils/is-campaign-sms-scenario.util.ts new file mode 100644 index 0000000..857d20a --- /dev/null +++ b/src/sms-settings/utils/is-campaign-sms-scenario.util.ts @@ -0,0 +1,14 @@ +import { SmsScenarioKey } from '../enums/sms-scenario-key.enum'; + +const CAMPAIGN_SMS_SCENARIO_KEYS = new Set([ + SmsScenarioKey.CAMPAIGN_RETURNING_CUSTOMERS, + SmsScenarioKey.CAMPAIGN_BIRTHDAY, + SmsScenarioKey.CAMPAIGN_AT_RISK, + SmsScenarioKey.CAMPAIGN_VIP, + SmsScenarioKey.CAMPAIGN_INACTIVE, + SmsScenarioKey.CAMPAIGN_COMPLEMENTARY, +]); + +export function isCampaignSmsScenarioKey(key: SmsScenarioKey): boolean { + return CAMPAIGN_SMS_SCENARIO_KEYS.has(key); +}