Files
grow-api/src/campaigns/campaign-automation.service.ts
T
2026-07-11 22:08:41 +03:30

184 lines
5.7 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SalonCustomer } from '../customers/entities/salon-customer.entity';
import { Salon } from '../salons/entities/salon.entity';
import { SalonSubscriptionsService } from '../subscriptions/salon-subscriptions.service';
import { CampaignTargetingService } from './campaign-targeting.service';
import { CampaignsService } from './campaigns.service';
import { CampaignTrigger } from './enums/campaign-trigger.enum';
import { CampaignType } from './enums/campaign-type.enum';
@Injectable()
export class CampaignAutomationService {
private readonly logger = new Logger(CampaignAutomationService.name);
constructor(
@InjectRepository(SalonCustomer)
private readonly salonCustomersRepository: Repository<SalonCustomer>,
@InjectRepository(Salon)
private readonly salonsRepository: Repository<Salon>,
private readonly targetingService: CampaignTargetingService,
private readonly campaignsService: CampaignsService,
private readonly salonSubscriptionsService: SalonSubscriptionsService,
) {}
/**
* Sends the "returning customers" reminder 4 days before the renewal due
* date, and again on the due date itself, for every salon.
*/
@Cron(CronExpression.EVERY_DAY_AT_9AM, {
name: 'campaign-returning-customers',
})
async runReturningCustomersCampaign(): Promise<number> {
const salonIds = await this.getSalonIdsWithCustomers();
let totalRunsSent = 0;
for (const salonId of salonIds) {
const contexts =
await this.targetingService.getSalonCustomerContexts(salonId);
const targets = this.targetingService.buildReturningCustomersTargets(
contexts,
{ exactOffsetsOnly: true },
);
if (targets.length === 0) {
continue;
}
const sent = await this.dispatchAutomaticCampaign(
salonId,
CampaignType.RETURNING_CUSTOMERS,
targets,
);
if (sent) {
totalRunsSent += 1;
}
}
this.logger.log(
`Returning-customers campaign processed for ${totalRunsSent} salons`,
);
return totalRunsSent;
}
/**
* Sends a birthday SMS to every customer on the day of their birthday, from
* every salon they are linked to (a customer registered at two salons will
* receive a birthday SMS from both).
*/
@Cron(CronExpression.EVERY_DAY_AT_9AM, { name: 'campaign-birthday' })
async runBirthdayCampaign(): Promise<number> {
const now = new Date();
const salonIds = await this.getSalonIdsWithBirthdayToday(now);
let totalRunsSent = 0;
for (const salonId of salonIds) {
const contexts =
await this.targetingService.getSalonCustomerContexts(salonId);
const targets = this.targetingService.buildBirthdayTargets(
contexts,
now,
{ exactDayOnly: true },
);
if (targets.length === 0) {
continue;
}
const sent = await this.dispatchAutomaticCampaign(
salonId,
CampaignType.BIRTHDAY,
targets,
);
if (sent) {
totalRunsSent += 1;
}
}
this.logger.log(`Birthday campaign processed for ${totalRunsSent} salons`);
return totalRunsSent;
}
private async dispatchAutomaticCampaign(
salonId: string,
type: CampaignType,
targets: Awaited<ReturnType<CampaignTargetingService['getTargetsForType']>>,
): Promise<boolean> {
const salon = await this.salonsRepository.findOne({
where: { id: salonId },
});
if (!salon) {
return false;
}
try {
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(
defaultTemplate.templateId,
defaultTemplate.template.body,
{ discountPercent: null, discountAmount: null },
salon,
targets,
);
await this.salonSubscriptionsService.consumeSms(salonId, smsParts);
} catch (error) {
this.logger.warn(
`Skipping ${type} campaign for salon ${salonId}: ${(error as Error).message}`,
);
await this.campaignsService.recordFailedRun({
salon,
type,
trigger: CampaignTrigger.AUTOMATIC,
targetCount: targets.length,
estimatedRevenue: this.campaignsService.sumEstimatedRevenue(targets),
});
return false;
}
await this.campaignsService.runCampaignForSalon({
salon,
type,
trigger: CampaignTrigger.AUTOMATIC,
targets,
triggeredByUserId: null,
templateId: (
await this.campaignsService.getDefaultTemplateForType(type)
)?.templateId,
});
return true;
}
private async getSalonIdsWithCustomers(): Promise<string[]> {
const rows = await this.salonCustomersRepository
.createQueryBuilder('salonCustomer')
.select('DISTINCT salonCustomer.salonId', 'salonId')
.getRawMany<{ salonId: string }>();
return rows.map((row) => row.salonId);
}
private async getSalonIdsWithBirthdayToday(now: Date): Promise<string[]> {
const rows = await this.salonCustomersRepository
.createQueryBuilder('salonCustomer')
.innerJoin('salonCustomer.customer', 'customer')
.select('DISTINCT salonCustomer.salonId', 'salonId')
.where('EXTRACT(MONTH FROM customer.dateOfBirth) = :month', {
month: now.getMonth() + 1,
})
.andWhere('EXTRACT(DAY FROM customer.dateOfBirth) = :day', {
day: now.getDate(),
})
.getRawMany<{ salonId: string }>();
return rows.map((row) => row.salonId);
}
}