Phase 8: Intelligence
Engagement scoring and churn prediction.
Deliverables
- Engagement scoring service
- Churn risk scoring service
- Batch scoring cron job
- Analytics API endpoints
1. Engagement Scoring Service
// libs/crm/core/src/services/engagement-scoring.service.ts
@Injectable()
export class EngagementScoringService {
private readonly weights = {
recency: 0.30,
frequency: 0.25,
monetary: 0.20,
breadth: 0.15,
depth: 0.10,
};
constructor(private readonly prisma: PrismaService) {}
async calculateScore(profile: CustomerProfile): Promise<number> {
const [recency, frequency, monetary, breadth, depth] = await Promise.all([
this.calculateRecencyScore(profile),
this.calculateFrequencyScore(profile),
this.calculateMonetaryScore(profile),
this.calculateBreadthScore(profile),
this.calculateDepthScore(profile),
]);
return Math.round(
recency * this.weights.recency +
frequency * this.weights.frequency +
monetary * this.weights.monetary +
breadth * this.weights.breadth +
depth * this.weights.depth
);
}
private calculateRecencyScore(profile: CustomerProfile): number {
if (!profile.lastActivityAt) return 0;
const daysSince = this.daysBetween(profile.lastActivityAt, new Date());
if (daysSince <= 7) return 100;
if (daysSince <= 14) return 80;
if (daysSince <= 30) return 60;
if (daysSince <= 60) return 40;
if (daysSince <= 90) return 20;
return 0;
}
private calculateFrequencyScore(profile: CustomerProfile): number {
const count = profile.activityCount30d ?? 0;
if (count >= 20) return 100;
if (count >= 10) return 80;
if (count >= 5) return 60;
if (count >= 2) return 40;
if (count >= 1) return 20;
return 0;
}
private calculateMonetaryScore(profile: CustomerProfile): number {
const ltv = Number(profile.lifetimeValue ?? 0);
// Adjust thresholds based on your data
if (ltv >= 100000) return 100; // R1000+
if (ltv >= 50000) return 80; // R500+
if (ltv >= 20000) return 60; // R200+
if (ltv >= 10000) return 40; // R100+
if (ltv >= 5000) return 20; // R50+
return 0;
}
private calculateBreadthScore(profile: CustomerProfile): number {
let channels = 0;
if (profile.emailOptIn) channels++;
if (profile.smsOptIn) channels++;
if (profile.pushOptIn) channels++;
if (profile.whatsappOptIn) channels++;
return channels * 25; // 0, 25, 50, 75, or 100
}
private async calculateDepthScore(profile: CustomerProfile): Promise<number> {
const thirtyDaysAgo = this.daysAgo(30);
const interactions = await this.prisma.campaignInteraction.findMany({
where: {
customerId: profile.id,
occurredAt: { gte: thirtyDaysAgo },
},
});
const sent = interactions.filter(i => i.type === 'SENT').length;
const opened = interactions.filter(i => i.type === 'OPENED').length;
const clicked = interactions.filter(i => i.type === 'CLICKED').length;
if (sent === 0) return 50; // Neutral if no campaigns sent
const openRate = opened / sent;
const clickRate = clicked / Math.max(opened, 1);
return Math.round((openRate * 50) + (clickRate * 50));
}
private daysBetween(date1: Date, date2: Date): number {
return Math.floor((date2.getTime() - date1.getTime()) / (1000 * 60 * 60 * 24));
}
private daysAgo(days: number): Date {
const date = new Date();
date.setDate(date.getDate() - days);
return date;
}
}
2. Churn Risk Service
// libs/crm/core/src/services/churn-risk.service.ts
@Injectable()
export class ChurnRiskService {
private readonly weights = {
activityDecline: 0.35,
paymentIssues: 0.25,
engagementDrop: 0.20,
tierDowngrade: 0.10,
complaints: 0.10,
};
constructor(private readonly prisma: PrismaService) {}
async calculateChurnRisk(profile: CustomerProfile): Promise<number> {
const [
activityDecline,
paymentRisk,
engagementDrop,
tierDowngrade,
complaints,
] = await Promise.all([
this.calculateActivityDeclineRisk(profile),
this.calculatePaymentRisk(profile),
this.calculateEngagementDropRisk(profile),
this.calculateTierDowngradeRisk(profile),
this.calculateComplaintRisk(profile),
]);
return Math.round(
activityDecline * this.weights.activityDecline +
paymentRisk * this.weights.paymentIssues +
engagementDrop * this.weights.engagementDrop +
tierDowngrade * this.weights.tierDowngrade +
complaints * this.weights.complaints
);
}
private async calculateActivityDeclineRisk(profile: CustomerProfile): Promise<number> {
// Compare 30-day vs 90-day activity
const recent = profile.activityCount30d ?? 0;
const older = (profile.activityCount90d ?? 0) - recent;
if (recent === 0) {
// No recent activity
if (!profile.lastActivityAt) return 100;
const daysSince = this.daysSince(profile.lastActivityAt);
if (daysSince > 90) return 100;
if (daysSince > 60) return 80;
if (daysSince > 30) return 60;
return 40;
}
if (older === 0) return 0; // Only recent activity, good sign
// Calculate decline percentage
const expectedRecent = older / 2; // Expect similar rate
const declineRate = (expectedRecent - recent) / expectedRecent;
if (declineRate >= 0.75) return 100;
if (declineRate >= 0.50) return 75;
if (declineRate >= 0.25) return 50;
if (declineRate > 0) return 25;
return 0;
}
private async calculatePaymentRisk(profile: CustomerProfile): Promise<number> {
const recentActivities = await this.prisma.activity.findMany({
where: {
customerId: profile.id,
type: { in: ['PAYMENT_FAILED', 'PAYMENT_RECEIVED'] },
occurredAt: { gte: this.daysAgo(90) },
},
orderBy: { occurredAt: 'desc' },
});
const failedCount = recentActivities.filter(a => a.type === 'PAYMENT_FAILED').length;
const successCount = recentActivities.filter(a => a.type === 'PAYMENT_RECEIVED').length;
if (failedCount === 0) return 0;
if (failedCount > 2) return 100;
if (failedCount > successCount) return 80;
return 40;
}
private async calculateEngagementDropRisk(profile: CustomerProfile): Promise<number> {
// Check if they've stopped opening emails
if (!profile.lastEmailOpenAt) return 50; // Neutral
const daysSinceOpen = this.daysSince(profile.lastEmailOpenAt);
if (daysSinceOpen > 60) return 100;
if (daysSinceOpen > 30) return 70;
if (daysSinceOpen > 14) return 40;
return 0;
}
private async calculateTierDowngradeRisk(profile: CustomerProfile): Promise<number> {
const recentTierChanges = await this.prisma.activity.findMany({
where: {
customerId: profile.id,
type: 'TIER_CHANGED',
occurredAt: { gte: this.daysAgo(180) },
},
orderBy: { occurredAt: 'desc' },
take: 1,
});
if (recentTierChanges.length === 0) return 0;
const change = recentTierChanges[0].metadata as any;
if (change?.reason === 'downgrade') return 80;
return 0;
}
private async calculateComplaintRisk(profile: CustomerProfile): Promise<number> {
const complaints = await this.prisma.customerNote.count({
where: {
customerId: profile.id,
type: 'COMPLAINT',
createdAt: { gte: this.daysAgo(90) },
},
});
if (complaints > 2) return 100;
if (complaints > 0) return 60;
return 0;
}
private daysSince(date: Date): number {
return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24));
}
private daysAgo(days: number): Date {
const date = new Date();
date.setDate(date.getDate() - days);
return date;
}
}
3. Batch Scoring Job
// libs/crm/core/src/jobs/batch-scoring.job.ts
@Injectable()
export class BatchScoringJob {
private readonly logger = new Logger(BatchScoringJob.name);
constructor(
private readonly prisma: PrismaService,
private readonly engagementService: EngagementScoringService,
private readonly churnService: ChurnRiskService,
) {}
@Cron('0 2 * * *') // Run at 2 AM daily
async runBatchScoring(): Promise<void> {
this.logger.log('Starting batch scoring job');
let cursor: string | undefined;
let processed = 0;
while (true) {
const profiles = await this.prisma.customerProfile.findMany({
where: { status: { not: 'MERGED' }, deletedAt: null },
take: 100,
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { id: 'asc' },
});
if (profiles.length === 0) break;
await Promise.all(
profiles.map(async profile => {
try {
const [engagement, churnRisk] = await Promise.all([
this.engagementService.calculateScore(profile),
this.churnService.calculateChurnRisk(profile),
]);
await this.prisma.customerProfile.update({
where: { id: profile.id },
data: {
engagementScore: engagement,
engagementScoreAt: new Date(),
churnRiskScore: churnRisk,
churnRiskScoreAt: new Date(),
},
});
// Emit event if high churn risk
if (churnRisk >= 80) {
await this.emitHighChurnRiskEvent(profile, churnRisk);
}
} catch (error) {
this.logger.error(`Failed to score profile ${profile.id}: ${error.message}`);
}
})
);
processed += profiles.length;
cursor = profiles[profiles.length - 1].id;
}
this.logger.log(`Batch scoring completed. Processed ${processed} profiles`);
}
private async emitHighChurnRiskEvent(profile: CustomerProfile, score: number): Promise<void> {
// Emit to event queue for alerting/automation
// This could trigger retention campaigns
}
}
4. Analytics Endpoints
// apps/crm/crm-backend/src/app/analytics/analytics.controller.ts
@ApiTags('analytics')
@Controller('api/v1/analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Get('engagement')
@ApiOperation({ summary: 'Get engagement distribution' })
async getEngagementDistribution(@Query('tenantId') tenantId: string) {
return this.analyticsService.getEngagementDistribution(tenantId);
}
@Get('churn-risk')
@ApiOperation({ summary: 'Get churn risk overview' })
async getChurnRiskOverview(@Query('tenantId') tenantId: string) {
return this.analyticsService.getChurnRiskOverview(tenantId);
}
}
// libs/crm/core/src/services/analytics.service.ts
@Injectable()
export class AnalyticsService {
constructor(private readonly prisma: PrismaService) {}
async getEngagementDistribution(tenantId: string) {
const ranges = [
{ label: 'High (70-100)', min: 70, max: 100 },
{ label: 'Medium (40-69)', min: 40, max: 69 },
{ label: 'Low (1-39)', min: 1, max: 39 },
{ label: 'Inactive (0)', min: 0, max: 0 },
];
const distribution = await Promise.all(
ranges.map(async range => ({
label: range.label,
count: await this.prisma.customerProfile.count({
where: {
tenantId,
status: 'ACTIVE',
engagementScore: { gte: range.min, lte: range.max },
},
}),
}))
);
return { distribution };
}
async getChurnRiskOverview(tenantId: string) {
const [critical, high, medium, low] = await Promise.all([
this.prisma.customerProfile.count({
where: { tenantId, status: 'ACTIVE', churnRiskScore: { gte: 80 } },
}),
this.prisma.customerProfile.count({
where: { tenantId, status: 'ACTIVE', churnRiskScore: { gte: 60, lt: 80 } },
}),
this.prisma.customerProfile.count({
where: { tenantId, status: 'ACTIVE', churnRiskScore: { gte: 30, lt: 60 } },
}),
this.prisma.customerProfile.count({
where: { tenantId, status: 'ACTIVE', churnRiskScore: { lt: 30 } },
}),
]);
return {
critical,
high,
medium,
low,
total: critical + high + medium + low,
};
}
}
Next Steps
After completing all phases, proceed to Testing and Deployment.