Files
grow-api/src/campaigns/campaign-targeting.service.ts
T
2026-07-09 19:12:23 +03:30

370 lines
11 KiB
TypeScript

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<string>;
}
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<string, string>;
}
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<SalonCustomer>,
@InjectRepository(Reserve)
private readonly reservesRepository: Repository<Reserve>,
@InjectRepository(ServiceSuggestion)
private readonly suggestionsRepository: Repository<ServiceSuggestion>,
) {}
async getSalonCustomerContexts(
salonId: string,
): Promise<SalonCustomerContext[]> {
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<string, Reserve[]>();
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<CampaignTargetCustomer[]> {
const contexts = await this.getSalonCustomerContexts(salonId);
return this.filterContextsForType(type, contexts);
}
async filterContextsForType(
type: CampaignType,
contexts: SalonCustomerContext[],
): Promise<CampaignTargetCustomer[]> {
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<CampaignTargetCustomer[]> {
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<string, ServiceSuggestion[]>();
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<string, string>,
): 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,
};
}
}