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, @InjectRepository(Salon) private readonly salonsRepository: Repository, 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 { 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 { 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>, ): Promise { 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 { 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 { 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); } }