184 lines
5.7 KiB
TypeScript
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);
|
|
}
|
|
}
|