import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { DEFAULT_RENEWAL_DAYS, daysBetween, isVipCustomer, parseDate, resolveRenewalDays, resolveSegment, } from '../customers/helpers/customer-segmentation.helper'; import { SalonCustomer } from '../customers/entities/salon-customer.entity'; import { Reserve } from '../reserves/entities/reserve.entity'; import { ReserveStatus } from '../reserves/enums/reserve-status.enum'; import { ServiceSuggestion } from '../suggestions/entities/service-suggestion.entity'; import { SuggestionType } from '../suggestions/enums/suggestion-type.enum'; import { BIRTHDAY_UPCOMING_WINDOW_DAYS, INACTIVE_CUSTOMER_DAYS_THRESHOLD, RENEWAL_REMINDER_LEAD_DAYS, } from './constants/campaign-types.constants'; import { CampaignType } from './enums/campaign-type.enum'; export interface SalonCustomerContext { customerId: string; firstName: string; lastName: string; mobile: string; dateOfBirth: string; totalPurchase: number; visitCount: number; lastVisitDate: string | null; daysSinceVisit: number | null; renewalDays: number; daysUntilRenewal: number | null; lastServiceTitle: string | null; lastServiceSkillIds: string[]; purchasedServiceSkillIds: Set; } export interface CampaignTargetCustomer { customerId: string; firstName: string; lastName: string; mobile: string; totalPurchase: number; visitCount: number; lastVisitDate: string | null; daysSinceVisit: number | null; estimatedRevenue: number; meta: Record; } function estimateVisitRevenue(context: { totalPurchase: number; visitCount: number; }): number { if (context.visitCount <= 0) { return 0; } return Math.round(context.totalPurchase / context.visitCount); } function daysUntilNextBirthday(dateOfBirth: string, now: Date): number { const parts = dateOfBirth.split('-').map(Number); const month = parts[1]; const day = parts[2]; const todayUtc = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); let next = Date.UTC(now.getFullYear(), month - 1, day); if (next < todayUtc) { next = Date.UTC(now.getFullYear() + 1, month - 1, day); } return Math.round((next - todayUtc) / (1000 * 60 * 60 * 24)); } @Injectable() export class CampaignTargetingService { constructor( @InjectRepository(SalonCustomer) private readonly salonCustomersRepository: Repository, @InjectRepository(Reserve) private readonly reservesRepository: Repository, @InjectRepository(ServiceSuggestion) private readonly suggestionsRepository: Repository, ) {} async getSalonCustomerContexts( salonId: string, ): Promise { const links = await this.salonCustomersRepository.find({ where: { salonId }, relations: { customer: { user: true } }, }); if (links.length === 0) { return []; } const customerIds = links.map((link) => link.customerId); const completedReserves = await this.reservesRepository.find({ where: { salonId, customerId: In(customerIds), status: ReserveStatus.COMPLETED, }, relations: { items: { serviceSkill: true, service: true } }, order: { appointmentDate: 'ASC' }, }); const reservesByCustomer = new Map(); for (const reserve of completedReserves) { const list = reservesByCustomer.get(reserve.customerId) ?? []; list.push(reserve); reservesByCustomer.set(reserve.customerId, list); } const now = new Date(); return links.map((link) => { const reserves = reservesByCustomer.get(link.customerId) ?? []; const totalPurchase = reserves.reduce( (sum, reserve) => sum + reserve.totalAmount, 0, ); const lastReserve = reserves[reserves.length - 1]; const lastVisitDate = lastReserve?.appointmentDate ?? null; const daysSinceVisit = lastVisitDate ? daysBetween(parseDate(lastVisitDate), now) : null; const renewalDays = lastReserve ? resolveRenewalDays(lastReserve.items) : DEFAULT_RENEWAL_DAYS; const daysUntilRenewal = daysSinceVisit !== null ? renewalDays - daysSinceVisit : null; const lastServiceTitle = lastReserve?.items?.[0]?.service?.title ?? null; const lastServiceSkillIds = lastReserve ? [...new Set(lastReserve.items.map((item) => item.serviceSkillId))] : []; const purchasedServiceSkillIds = new Set( reserves.flatMap((reserve) => reserve.items.map((item) => item.serviceSkillId), ), ); return { customerId: link.customerId, firstName: link.customer.user.firstName, lastName: link.customer.user.lastName, mobile: link.customer.user.mobile, dateOfBirth: link.customer.dateOfBirth, totalPurchase, visitCount: reserves.length, lastVisitDate, daysSinceVisit, renewalDays, daysUntilRenewal, lastServiceTitle, lastServiceSkillIds, purchasedServiceSkillIds, }; }); } async getTargetsForType( type: CampaignType, salonId: string, ): Promise { const contexts = await this.getSalonCustomerContexts(salonId); return this.filterContextsForType(type, contexts); } async filterContextsForType( type: CampaignType, contexts: SalonCustomerContext[], ): Promise { switch (type) { case CampaignType.RETURNING_CUSTOMERS: return this.buildReturningCustomersTargets(contexts, { exactOffsetsOnly: false, }); case CampaignType.BIRTHDAY: return this.buildBirthdayTargets(contexts); case CampaignType.AT_RISK: return this.buildAtRiskTargets(contexts); case CampaignType.VIP: return this.buildVipTargets(contexts); case CampaignType.INACTIVE: return this.buildInactiveTargets(contexts); case CampaignType.COMPLEMENTARY_SERVICE: return this.buildComplementaryServiceTargets(contexts); default: return []; } } buildReturningCustomersTargets( contexts: SalonCustomerContext[], options: { exactOffsetsOnly: boolean }, ): CampaignTargetCustomer[] { return contexts .filter((context) => context.daysUntilRenewal !== null) .filter((context) => { const daysUntilRenewal = context.daysUntilRenewal as number; if (options.exactOffsetsOnly) { return ( daysUntilRenewal === 0 || daysUntilRenewal === RENEWAL_REMINDER_LEAD_DAYS ); } return ( daysUntilRenewal >= 0 && daysUntilRenewal <= RENEWAL_REMINDER_LEAD_DAYS ); }) .map((context) => this.toTargetCustomer(context, { daysUntilRenewal: String(context.daysUntilRenewal ?? ''), lastServiceName: context.lastServiceTitle ?? 'خدمت قبلی', }), ); } buildBirthdayTargets( contexts: SalonCustomerContext[], now: Date = new Date(), options: { exactDayOnly?: boolean } = {}, ): CampaignTargetCustomer[] { return contexts .map((context) => ({ context, daysUntilBirthday: daysUntilNextBirthday(context.dateOfBirth, now), })) .filter(({ daysUntilBirthday }) => options.exactDayOnly ? daysUntilBirthday === 0 : daysUntilBirthday <= BIRTHDAY_UPCOMING_WINDOW_DAYS, ) .map(({ context, daysUntilBirthday }) => this.toTargetCustomer(context, { daysUntilBirthday: String(daysUntilBirthday), }), ); } buildAtRiskTargets( contexts: SalonCustomerContext[], ): CampaignTargetCustomer[] { return contexts .filter( (context) => context.daysSinceVisit !== null && resolveSegment(context.daysSinceVisit, context.renewalDays) === 'at-risk', ) .map((context) => this.toTargetCustomer(context, { daysSinceVisit: String(context.daysSinceVisit ?? ''), }), ); } buildVipTargets(contexts: SalonCustomerContext[]): CampaignTargetCustomer[] { return contexts .filter((context) => isVipCustomer(context.totalPurchase)) .map((context) => this.toTargetCustomer(context, { totalPurchase: context.totalPurchase.toLocaleString('fa-IR'), }), ); } buildInactiveTargets( contexts: SalonCustomerContext[], ): CampaignTargetCustomer[] { return contexts .filter( (context) => context.daysSinceVisit !== null && context.daysSinceVisit > INACTIVE_CUSTOMER_DAYS_THRESHOLD, ) .map((context) => this.toTargetCustomer(context, { daysSinceVisit: String(context.daysSinceVisit ?? ''), }), ); } async buildComplementaryServiceTargets( contexts: SalonCustomerContext[], ): Promise { const candidateContexts = contexts.filter( (context) => context.lastServiceSkillIds.length > 0, ); if (candidateContexts.length === 0) { return []; } const serviceSkillIds = [ ...new Set( candidateContexts.flatMap((context) => context.lastServiceSkillIds), ), ]; const suggestions = await this.suggestionsRepository.find({ where: { serviceSkillId: In(serviceSkillIds), isActive: true, type: SuggestionType.COMPLEMENTARY, }, relations: { suggestedServiceSkill: { service: true } }, order: { displayPriority: 'ASC' }, }); const suggestionsBySkill = new Map(); for (const suggestion of suggestions) { const list = suggestionsBySkill.get(suggestion.serviceSkillId) ?? []; list.push(suggestion); suggestionsBySkill.set(suggestion.serviceSkillId, list); } const targets: CampaignTargetCustomer[] = []; for (const context of candidateContexts) { const matchingSuggestion = context.lastServiceSkillIds .flatMap((skillId) => suggestionsBySkill.get(skillId) ?? []) .find( (suggestion) => !context.purchasedServiceSkillIds.has( suggestion.suggestedServiceSkillId, ), ); if (!matchingSuggestion) { continue; } targets.push( this.toTargetCustomer(context, { suggestedServiceName: matchingSuggestion.suggestedServiceSkill?.service?.title ?? matchingSuggestion.suggestedServiceSkill?.title ?? 'خدمت مکمل', }), ); } return targets; } private toTargetCustomer( context: SalonCustomerContext, meta: Record, ): CampaignTargetCustomer { return { customerId: context.customerId, firstName: context.firstName, lastName: context.lastName, mobile: context.mobile, totalPurchase: context.totalPurchase, visitCount: context.visitCount, lastVisitDate: context.lastVisitDate, daysSinceVisit: context.daysSinceVisit, estimatedRevenue: estimateVisitRevenue(context), meta, }; } }