templete campain

This commit is contained in:
2026-07-11 22:08:41 +03:30
parent bf4e4c8d65
commit b1da7fdeac
19 changed files with 867 additions and 37 deletions
+12 -3
View File
@@ -9,7 +9,6 @@ import { CampaignTargetingService } from './campaign-targeting.service';
import { CampaignsService } from './campaigns.service'; import { CampaignsService } from './campaigns.service';
import { CampaignTrigger } from './enums/campaign-trigger.enum'; import { CampaignTrigger } from './enums/campaign-trigger.enum';
import { CampaignType } from './enums/campaign-type.enum'; import { CampaignType } from './enums/campaign-type.enum';
import { getCampaignTypeDefinition } from './constants/campaign-types.constants';
@Injectable() @Injectable()
export class CampaignAutomationService { export class CampaignAutomationService {
@@ -115,9 +114,16 @@ export class CampaignAutomationService {
} }
try { 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( const smsParts = await this.campaignsService.calculateCampaignSmsParts(
definition.scenarioKey, defaultTemplate.templateId,
defaultTemplate.template.body,
{ discountPercent: null, discountAmount: null },
salon, salon,
targets, targets,
); );
@@ -142,6 +148,9 @@ export class CampaignAutomationService {
trigger: CampaignTrigger.AUTOMATIC, trigger: CampaignTrigger.AUTOMATIC,
targets, targets,
triggeredByUserId: null, triggeredByUserId: null,
templateId: (
await this.campaignsService.getDefaultTemplateForType(type)
)?.templateId,
}); });
return true; return true;
@@ -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<CampaignTypeTemplate>,
private readonly smsTemplatesService: SmsTemplatesService,
) {}
async findAllGrouped(): Promise<CampaignTypeTemplate[]> {
return this.assignmentsRepository.find({
relations: { template: true },
order: { campaignType: 'ASC', sortOrder: 'ASC', createdAt: 'ASC' },
});
}
async findByCampaignType(
campaignType: CampaignType,
): Promise<CampaignTypeTemplate[]> {
getCampaignTypeDefinition(campaignType);
return this.assignmentsRepository.find({
where: { campaignType },
relations: { template: true },
order: { sortOrder: 'ASC', createdAt: 'ASC' },
});
}
async findActiveByCampaignType(
campaignType: CampaignType,
): Promise<CampaignTypeTemplate[]> {
const assignments = await this.findByCampaignType(campaignType);
return assignments.filter((assignment) => assignment.template.isActive);
}
async findDefaultForCampaignType(
campaignType: CampaignType,
): Promise<CampaignTypeTemplate | null> {
const active = await this.findActiveByCampaignType(campaignType);
return (
active.find((assignment) => assignment.isDefault) ?? active[0] ?? null
);
}
async resolveAssignment(
campaignType: CampaignType,
templateId?: string | null,
): Promise<CampaignTypeTemplate> {
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<CampaignTypeTemplate> {
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<CampaignTypeTemplate> {
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<void> {
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<CampaignTypeTemplate> {
const assignment = await this.assignmentsRepository.findOne({
where: { id },
relations: { template: true },
});
if (!assignment) {
throw new NotFoundException(`Campaign template assignment #${id} not found`);
}
return assignment;
}
}
+63
View File
@@ -1,9 +1,11 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Patch,
Post, Post,
Query, Query,
UseGuards, UseGuards,
@@ -18,15 +20,20 @@ import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { PermissionCode } from '../authorization/enums/permission-code.enum'; import { PermissionCode } from '../authorization/enums/permission-code.enum';
import { import {
ApiStandardController, ApiStandardController,
ApiStandardEmptyResponse,
ApiStandardOkResponse, ApiStandardOkResponse,
} from '../swagger/decorators/swagger-response.decorator'; } from '../swagger/decorators/swagger-response.decorator';
import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup'; import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup';
import { UserRole } from '../users/enums/user-role.enum'; import { UserRole } from '../users/enums/user-role.enum';
import { CampaignAutomationService } from './campaign-automation.service'; import { CampaignAutomationService } from './campaign-automation.service';
import { CampaignTypeTemplatesService } from './campaign-type-templates.service';
import { CampaignsService } from './campaigns.service'; import { CampaignsService } from './campaigns.service';
import { AssignCampaignTemplateDto } from './dto/assign-campaign-template.dto';
import { FindCampaignRunsQueryDto } from './dto/find-campaign-runs-query.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 { SendCampaignDto } from './dto/send-campaign.dto';
import { CampaignRun } from './entities/campaign-run.entity'; import { CampaignRun } from './entities/campaign-run.entity';
import { CampaignTypeTemplate } from './entities/campaign-type-template.entity';
import { CampaignType } from './enums/campaign-type.enum'; import { CampaignType } from './enums/campaign-type.enum';
@ApiTags('Campaigns') @ApiTags('Campaigns')
@@ -38,6 +45,7 @@ export class CampaignsController {
constructor( constructor(
private readonly campaignsService: CampaignsService, private readonly campaignsService: CampaignsService,
private readonly campaignAutomationService: CampaignAutomationService, private readonly campaignAutomationService: CampaignAutomationService,
private readonly campaignTypeTemplatesService: CampaignTypeTemplatesService,
) {} ) {}
@Roles(UserRole.SALON) @Roles(UserRole.SALON)
@@ -58,6 +66,38 @@ export class CampaignsController {
return this.campaignsService.findRuns(user, query); 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) @Roles(UserRole.SALON, UserRole.ADMIN)
@Permissions(PermissionCode.CAMPAIGNS_READ) @Permissions(PermissionCode.CAMPAIGNS_READ)
@ApiStandardOkResponse(CampaignRun) @ApiStandardOkResponse(CampaignRun)
@@ -80,6 +120,29 @@ export class CampaignsController {
return this.campaignsService.getRunRecipients(id, user); 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) @Roles(UserRole.SALON)
@ApiStandardOkResponse(Object, { isArray: true }) @ApiStandardOkResponse(Object, { isArray: true })
@Get(':type/targets') @Get(':type/targets')
+11 -1
View File
@@ -9,19 +9,23 @@ import { Salon } from '../salons/entities/salon.entity';
import { SentMessage } from '../sent-messages/entities/sent-message.entity'; import { SentMessage } from '../sent-messages/entities/sent-message.entity';
import { SmsModule } from '../sms/sms.module'; import { SmsModule } from '../sms/sms.module';
import { SmsSettingsModule } from '../sms-settings/sms-settings.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 { ServiceSuggestion } from '../suggestions/entities/service-suggestion.entity';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
import { CampaignAutomationService } from './campaign-automation.service'; import { CampaignAutomationService } from './campaign-automation.service';
import { CampaignTargetingService } from './campaign-targeting.service'; import { CampaignTargetingService } from './campaign-targeting.service';
import { CampaignTypeTemplatesService } from './campaign-type-templates.service';
import { CampaignsController } from './campaigns.controller'; import { CampaignsController } from './campaigns.controller';
import { CampaignsService } from './campaigns.service'; import { CampaignsService } from './campaigns.service';
import { CampaignRun } from './entities/campaign-run.entity'; import { CampaignRun } from './entities/campaign-run.entity';
import { CampaignTypeTemplate } from './entities/campaign-type-template.entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ TypeOrmModule.forFeature([
Salon, Salon,
CampaignRun, CampaignRun,
CampaignTypeTemplate,
SentMessage, SentMessage,
SalonCustomer, SalonCustomer,
Reserve, Reserve,
@@ -29,6 +33,7 @@ import { CampaignRun } from './entities/campaign-run.entity';
]), ]),
SmsModule, SmsModule,
SmsSettingsModule, SmsSettingsModule,
SmsTemplatesModule,
NotificationsModule, NotificationsModule,
SubscriptionsModule, SubscriptionsModule,
AuthorizationModule, AuthorizationModule,
@@ -39,7 +44,12 @@ import { CampaignRun } from './entities/campaign-run.entity';
CampaignsService, CampaignsService,
CampaignTargetingService, CampaignTargetingService,
CampaignAutomationService, CampaignAutomationService,
CampaignTypeTemplatesService,
],
exports: [
CampaignsService,
CampaignTargetingService,
CampaignTypeTemplatesService,
], ],
exports: [CampaignsService, CampaignTargetingService],
}) })
export class CampaignsModule {} export class CampaignsModule {}
+138 -29
View File
@@ -21,6 +21,7 @@ import {
CampaignTargetCustomer, CampaignTargetCustomer,
CampaignTargetingService, CampaignTargetingService,
} from './campaign-targeting.service'; } from './campaign-targeting.service';
import { CampaignTypeTemplatesService } from './campaign-type-templates.service';
import { import {
CAMPAIGN_TYPES, CAMPAIGN_TYPES,
getCampaignTypeDefinition, getCampaignTypeDefinition,
@@ -28,9 +29,21 @@ import {
import { FindCampaignRunsQueryDto } from './dto/find-campaign-runs-query.dto'; import { FindCampaignRunsQueryDto } from './dto/find-campaign-runs-query.dto';
import { SendCampaignDto } from './dto/send-campaign.dto'; import { SendCampaignDto } from './dto/send-campaign.dto';
import { CampaignRun } from './entities/campaign-run.entity'; import { CampaignRun } from './entities/campaign-run.entity';
import { CampaignTypeTemplate } from './entities/campaign-type-template.entity';
import { CampaignRunStatus } from './enums/campaign-run-status.enum'; import { CampaignRunStatus } from './enums/campaign-run-status.enum';
import { CampaignTrigger } from './enums/campaign-trigger.enum'; import { CampaignTrigger } from './enums/campaign-trigger.enum';
import { CampaignType } from './enums/campaign-type.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 { export interface CampaignOverviewItem {
type: CampaignType; type: CampaignType;
@@ -59,6 +72,7 @@ export class CampaignsService {
private readonly salonSubscriptionsService: SalonSubscriptionsService, private readonly salonSubscriptionsService: SalonSubscriptionsService,
private readonly salonNotificationsService: SalonNotificationsService, private readonly salonNotificationsService: SalonNotificationsService,
private readonly authorizationService: AuthorizationService, private readonly authorizationService: AuthorizationService,
private readonly campaignTypeTemplatesService: CampaignTypeTemplatesService,
) {} ) {}
async getOverview(user: AuthUser): Promise<CampaignOverviewItem[]> { async getOverview(user: AuthUser): Promise<CampaignOverviewItem[]> {
@@ -98,6 +112,56 @@ export class CampaignsService {
return this.targetingService.getTargetsForType(type, salon.id); return this.targetingService.getTargetsForType(type, salon.id);
} }
async getTemplates(
type: CampaignType,
user: AuthUser,
): Promise<CampaignTypeTemplate[]> {
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<string> {
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( async sendCampaign(
type: CampaignType, type: CampaignType,
dto: SendCampaignDto, dto: SendCampaignDto,
@@ -126,18 +190,28 @@ export class CampaignsService {
throw new BadRequestException('مشتری هدفی برای این کمپین یافت نشد'); throw new BadRequestException('مشتری هدفی برای این کمپین یافت نشد');
} }
const sampleMessage = await this.smsDispatchService.resolveMessage( const assignment = await this.campaignTypeTemplatesService.resolveAssignment(
definition.scenarioKey, 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) { if (!sampleMessage) {
throw new BadRequestException( throw new BadRequestException('قالب انتخاب‌شده فعال نیست');
'متن پیامک این کمپین در تنظیمات پیامک ادمین فعال نشده است',
);
} }
const smsParts = await this.calculateCampaignSmsParts( const smsParts = await this.calculateCampaignSmsParts(
definition.scenarioKey, assignment.templateId,
assignment.template.body,
discount,
salon, salon,
targets, targets,
); );
@@ -149,6 +223,9 @@ export class CampaignsService {
trigger: CampaignTrigger.MANUAL, trigger: CampaignTrigger.MANUAL,
targets, targets,
triggeredByUserId: user.id, triggeredByUserId: user.id,
templateId: assignment.templateId,
discountPercent: discount.discountPercent,
discountAmount: discount.discountAmount,
}); });
return this.findRun(run.id, user); 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 * 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 * manual send endpoint and the automatic (cron-driven) campaigns. SMS balance
* must already have been consumed by the caller before invoking this method. * 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: { async runCampaignForSalon(params: {
salon: Salon; salon: Salon;
@@ -165,8 +243,15 @@ export class CampaignsService {
trigger: CampaignTrigger; trigger: CampaignTrigger;
targets: CampaignTargetCustomer[]; targets: CampaignTargetCustomer[];
triggeredByUserId?: string | null; triggeredByUserId?: string | null;
templateId?: string | null;
discountPercent?: number | null;
discountAmount?: number | null;
}): Promise<CampaignRun> { }): Promise<CampaignRun> {
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( const run = await this.campaignRunsRepository.save(
this.campaignRunsRepository.create({ this.campaignRunsRepository.create({
@@ -179,13 +264,18 @@ export class CampaignsService {
failedCount: 0, failedCount: 0,
estimatedRevenue: this.sumEstimatedRevenue(params.targets), estimatedRevenue: this.sumEstimatedRevenue(params.targets),
triggeredByUserId: params.triggeredByUserId ?? null, triggeredByUserId: params.triggeredByUserId ?? null,
templateId: assignment.templateId,
discountPercent: discount.discountPercent,
discountAmount: discount.discountAmount,
}), }),
); );
await this.dispatchToTargets( await this.dispatchToTargets(
run, run,
params.salon, params.salon,
definition.scenarioKey, assignment.templateId,
assignment.template.body,
discount,
params.targets, params.targets,
); );
@@ -228,27 +318,29 @@ export class CampaignsService {
} }
async calculateCampaignSmsParts( async calculateCampaignSmsParts(
scenarioKey: (typeof CAMPAIGN_TYPES)[number]['scenarioKey'], templateId: string,
templateBody: string,
discount: CampaignDiscountFields,
salon: Salon, salon: Salon,
targets: CampaignTargetCustomer[], targets: CampaignTargetCustomer[],
): Promise<number> { ): Promise<number> {
let totalParts = 0; let totalParts = 0;
for (const target of targets) { for (const target of targets) {
const variables: Record<string, string> = { const variables = buildCampaignMessageVariables(salon, target, discount);
customerName:
`${target.firstName} ${target.lastName}`.trim() || 'مشتری',
salonName: salon.name,
...target.meta,
};
const message = await this.smsDispatchService.resolveMessage( const message = await this.smsDispatchService.resolveMessageFromTemplateId(
scenarioKey, templateId,
variables, variables,
); );
if (message) { 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<CampaignRun> { async findRun(id: string, user: AuthUser): Promise<CampaignRun> {
const run = await this.campaignRunsRepository.findOne({ const run = await this.campaignRunsRepository.findOne({
where: { id }, where: { id },
relations: { salon: true, triggeredByUser: true }, relations: { salon: true, triggeredByUser: true, template: true },
}); });
if (!run) { if (!run) {
throw new NotFoundException(`Campaign run #${id} not found`); 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( private async dispatchToTargets(
run: CampaignRun, run: CampaignRun,
salon: Salon, salon: Salon,
scenarioKey: (typeof CAMPAIGN_TYPES)[number]['scenarioKey'], templateId: string,
templateBody: string,
discount: CampaignDiscountFields,
targets: CampaignTargetCustomer[], targets: CampaignTargetCustomer[],
): Promise<void> { ): Promise<void> {
let sentCount = 0; let sentCount = 0;
let failedCount = 0; let failedCount = 0;
for (const target of targets) { for (const target of targets) {
const variables: Record<string, string> = { const variables = buildCampaignMessageVariables(salon, target, discount);
customerName:
`${target.firstName} ${target.lastName}`.trim() || 'مشتری',
salonName: salon.name,
...target.meta,
};
const message = await this.smsDispatchService.resolveMessage( const rawMessage = await this.smsDispatchService.resolveMessageFromTemplateId(
scenarioKey, templateId,
variables, variables,
); );
const message = rawMessage
? applyCampaignDiscountFallback(rawMessage, templateBody, discount)
: rawMessage;
let status = SentMessageStatus.FAILED; let status = SentMessageStatus.FAILED;
if (message) { 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) { private ensureValidType(type: CampaignType) {
try { try {
return getCampaignTypeDefinition(type); return getCampaignTypeDefinition(type);
@@ -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;
}
@@ -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 (1100)' })
@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;
}
@@ -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;
}
+9 -2
View File
@@ -1,7 +1,14 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsOptional, IsUUID } from 'class-validator'; 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({ @ApiPropertyOptional({
description: description:
'Optional subset of currently targeted customer ids to send to. When omitted, all current targets receive the campaign.', 'Optional subset of currently targeted customer ids to send to. When omitted, all current targets receive the campaign.',
@@ -9,6 +9,7 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { Salon } from '../../salons/entities/salon.entity'; import { Salon } from '../../salons/entities/salon.entity';
import { SmsTemplate } from '../../sms-templates/entities/sms-template.entity';
import { User } from '../../users/entities/user.entity'; import { User } from '../../users/entities/user.entity';
import { CampaignRunStatus } from '../enums/campaign-run-status.enum'; import { CampaignRunStatus } from '../enums/campaign-run-status.enum';
import { CampaignTrigger } from '../enums/campaign-trigger.enum'; import { CampaignTrigger } from '../enums/campaign-trigger.enum';
@@ -77,6 +78,29 @@ export class CampaignRun {
@JoinColumn({ name: 'triggeredByUserId' }) @JoinColumn({ name: 'triggeredByUserId' })
triggeredByUser: User | null; 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() @CreateDateColumn()
createdAt: Date; createdAt: Date;
@@ -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;
}
@@ -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<string, string> {
return {
customerName:
`${target.firstName} ${target.lastName}`.trim() || 'مشتری',
salonName: salon.name,
discount: formatCampaignDiscount(discount),
...target.meta,
};
}
export function buildCampaignPreviewVariables(
salonName: string,
sampleVariables: Record<string, string>,
discount: CampaignDiscountFields,
): Record<string, string> {
return {
...sampleVariables,
salonName,
discount: formatCampaignDiscount(discount),
};
}
@@ -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}`;
}
@@ -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<void> {
await this.createCampaignTypeTemplatesTable(queryRunner);
await this.addTemplateIdToCampaignRuns(queryRunner);
await this.seedCampaignTypeTemplates(queryRunner);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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<void> {
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<void> {
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<void> {
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],
);
}
}
}
@@ -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<void> {
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<void> {
await queryRunner.dropColumn('campaign_runs', 'discountAmount');
await queryRunner.dropColumn('campaign_runs', 'discountPercent');
}
}
@@ -15,6 +15,13 @@ export interface SmsScenarioDefinition {
variables: SmsScenarioVariable[]; variables: SmsScenarioVariable[];
} }
export const CAMPAIGN_DISCOUNT_VARIABLE: SmsScenarioVariable = {
key: 'discount',
label: 'تخفیف',
description: 'متن تخفیف تعریف‌شده برای قالب کمپین (مثلاً ۲۰٪ تخفیف)',
sampleValue: '۲۰٪ تخفیف',
};
export const SMS_SCENARIOS: SmsScenarioDefinition[] = [ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
{ {
key: SmsScenarioKey.OTP_LOGIN, key: SmsScenarioKey.OTP_LOGIN,
@@ -255,6 +262,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
description: 'تعداد روز باقی‌مانده تا موعد تجدید (۰ یعنی امروز)', description: 'تعداد روز باقی‌مانده تا موعد تجدید (۰ یعنی امروز)',
sampleValue: '۴', sampleValue: '۴',
}, },
CAMPAIGN_DISCOUNT_VARIABLE,
], ],
}, },
{ {
@@ -277,6 +285,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
description: 'نام سالن زیبایی', description: 'نام سالن زیبایی',
sampleValue: 'سالن زیبایی رز', sampleValue: 'سالن زیبایی رز',
}, },
CAMPAIGN_DISCOUNT_VARIABLE,
], ],
}, },
{ {
@@ -305,6 +314,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
description: 'تعداد روزهایی که از آخرین مراجعه مشتری گذشته است', description: 'تعداد روزهایی که از آخرین مراجعه مشتری گذشته است',
sampleValue: '۴۰', sampleValue: '۴۰',
}, },
CAMPAIGN_DISCOUNT_VARIABLE,
], ],
}, },
{ {
@@ -333,6 +343,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
description: 'مجموع خرید مشتری تا کنون (تومان)', description: 'مجموع خرید مشتری تا کنون (تومان)',
sampleValue: '۱۵,۰۰۰,۰۰۰', sampleValue: '۱۵,۰۰۰,۰۰۰',
}, },
CAMPAIGN_DISCOUNT_VARIABLE,
], ],
}, },
{ {
@@ -361,6 +372,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
description: 'تعداد روزهایی که از آخرین مراجعه مشتری گذشته است', description: 'تعداد روزهایی که از آخرین مراجعه مشتری گذشته است',
sampleValue: '۹۰', sampleValue: '۹۰',
}, },
CAMPAIGN_DISCOUNT_VARIABLE,
], ],
}, },
{ {
@@ -389,6 +401,7 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
description: 'نام خدمت مکمل پیشنهادی', description: 'نام خدمت مکمل پیشنهادی',
sampleValue: 'کراتین', sampleValue: 'کراتین',
}, },
CAMPAIGN_DISCOUNT_VARIABLE,
], ],
}, },
{ {
+12
View File
@@ -63,6 +63,18 @@ export class SmsDispatchService {
return renderSmsTemplate(body, variables); return renderSmsTemplate(body, variables);
} }
async resolveMessageFromTemplateId(
templateId: string,
variables: Record<string, string>,
): Promise<string | null> {
const template = await this.smsTemplatesService.findOne(templateId);
if (!template.isActive) {
return null;
}
return renderSmsTemplate(template.body, variables);
}
async previewMessage(dto: PreviewSmsScenarioDto): Promise<string> { async previewMessage(dto: PreviewSmsScenarioDto): Promise<string> {
const body = await this.resolvePreviewBody(dto); const body = await this.resolvePreviewBody(dto);
const variables = getSampleVariables(dto.key); const variables = getSampleVariables(dto.key);
+9 -2
View File
@@ -20,6 +20,7 @@ import {
import { SmsScenarioSetting } from './entities/sms-scenario-setting.entity'; import { SmsScenarioSetting } from './entities/sms-scenario-setting.entity';
import { SmsScenarioKey } from './enums/sms-scenario-key.enum'; import { SmsScenarioKey } from './enums/sms-scenario-key.enum';
import { SmsDispatchService } from './sms-dispatch.service'; import { SmsDispatchService } from './sms-dispatch.service';
import { isCampaignSmsScenarioKey } from './utils/is-campaign-sms-scenario.util';
export interface SmsScenarioSettingResponse { export interface SmsScenarioSettingResponse {
key: SmsScenarioKey; key: SmsScenarioKey;
@@ -42,14 +43,20 @@ export class SmsSettingsService {
) {} ) {}
getScenarioDefinitions(): SmsScenarioDefinition[] { getScenarioDefinitions(): SmsScenarioDefinition[] {
return this.smsDispatchService.getScenarioDefinitions(); return this.smsDispatchService
.getScenarioDefinitions()
.filter((definition) => !isCampaignSmsScenarioKey(definition.key));
} }
async findAll(user: AuthUser): Promise<SmsScenarioSettingResponse[]> { async findAll(user: AuthUser): Promise<SmsScenarioSettingResponse[]> {
this.ensureCanManage(user); this.ensureCanManage(user);
const settings = await this.smsDispatchService.findAllSettings(); 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( async update(
@@ -0,0 +1,14 @@
import { SmsScenarioKey } from '../enums/sms-scenario-key.enum';
const CAMPAIGN_SMS_SCENARIO_KEYS = new Set<SmsScenarioKey>([
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);
}