templete campain
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 (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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user