sms-setting and campain
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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 {
|
||||
await this.salonSubscriptionsService.consumeSms(salonId, targets.length);
|
||||
} 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,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user