Files
QR-master/src/lib/revops-server.ts
2026-07-07 00:10:29 +02:00

387 lines
10 KiB
TypeScript

import { db } from '@/lib/db';
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
import {
getEmailDomain,
isFreemailDomain,
LifecycleStage,
normalizeSource,
} from '@/lib/revops';
type ScoreReason =
| 'signup'
| 'onboarding_update'
| 'qr_created'
| 'scan_recorded'
| 'subscription_changed'
| 'subscription_created'
| 'subscription_updated'
| 'subscription_canceled_at_period_end'
| 'subscription_deleted'
| 'subscription_synced';
type UserForScoring = {
id: string;
email: string;
plan: string;
primaryUseCase: string | null;
primaryGoal: string | null;
jobRole: string | null;
companyName: string | null;
teamSizeBucket: string | null;
firstQrCreatedAt: Date | null;
firstDynamicQrAt: Date | null;
firstStaticQrAt: Date | null;
firstScanAt: Date | null;
activationAt: Date | null;
onboardingCompletedAt: Date | null;
lastQualifiedAt: Date | null;
lifecycleStage: string;
};
type UserMetricSnapshot = {
qrCount: number;
dynamicQrCount: number;
contentTypeCount: number;
businessishTypeCount: number;
scanCount: number;
firstQrCreatedAt: Date | null;
firstDynamicQrAt: Date | null;
firstStaticQrAt: Date | null;
};
export function triggerLifecycleScoring(userId: string, reason: ScoreReason) {
void scoreUserLifecycle(userId, reason).catch((error) => {
console.error(`Lifecycle scoring failed for ${userId} (${reason}):`, error);
});
}
export async function scoreUserLifecycle(userId: string, reason: ScoreReason) {
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
plan: true,
primaryUseCase: true,
primaryGoal: true,
jobRole: true,
companyName: true,
teamSizeBucket: true,
firstQrCreatedAt: true,
firstDynamicQrAt: true,
firstStaticQrAt: true,
firstScanAt: true,
activationAt: true,
onboardingCompletedAt: true,
lastQualifiedAt: true,
lifecycleStage: true,
},
});
if (!user) {
return null;
}
const qrCodes = await db.qRCode.findMany({
where: { userId },
select: {
id: true,
type: true,
contentType: true,
createdAt: true,
_count: {
select: {
scans: true,
},
},
},
});
const firstScan = await db.qRScan.findFirst({
where: {
qr: {
userId,
},
},
orderBy: {
ts: 'asc',
},
select: {
ts: true,
},
});
const metrics = getMetricSnapshot(qrCodes);
const computedTimestamps = {
firstQrCreatedAt: user.firstQrCreatedAt ?? metrics.firstQrCreatedAt,
firstDynamicQrAt: user.firstDynamicQrAt ?? metrics.firstDynamicQrAt,
firstStaticQrAt: user.firstStaticQrAt ?? metrics.firstStaticQrAt,
firstScanAt: user.firstScanAt ?? firstScan?.ts ?? null,
activationAt: user.activationAt ?? user.firstScanAt ?? firstScan?.ts ?? null,
onboardingCompletedAt:
user.onboardingCompletedAt ?? metrics.firstQrCreatedAt,
};
const fitScore = calculateFitScore(user);
const intentScore = calculateIntentScore({
...computedTimestamps,
...metrics,
});
const leadScore = fitScore + intentScore;
const nextStage = resolveLifecycleStage({
plan: user.plan,
leadScore,
activationAt: computedTimestamps.activationAt,
});
const shouldRefreshQualifiedAt = nextStage === 'paid' || nextStage === 'hot' || nextStage === 'upgrade_candidate';
const updatedUser = await db.user.update({
where: { id: userId },
data: {
emailDomain: getEmailDomain(user.email),
firstQrCreatedAt: computedTimestamps.firstQrCreatedAt,
firstDynamicQrAt: computedTimestamps.firstDynamicQrAt,
firstStaticQrAt: computedTimestamps.firstStaticQrAt,
firstScanAt: computedTimestamps.firstScanAt,
activationAt: computedTimestamps.activationAt,
onboardingCompletedAt: computedTimestamps.onboardingCompletedAt,
fitScore,
intentScore,
leadScore,
lifecycleStage: nextStage,
lastScoredAt: new Date(),
lastQualifiedAt: shouldRefreshQualifiedAt ? new Date() : user.lastQualifiedAt,
},
select: {
id: true,
lifecycleStage: true,
fitScore: true,
intentScore: true,
leadScore: true,
firstQrCreatedAt: true,
firstDynamicQrAt: true,
firstScanAt: true,
activationAt: true,
},
});
const isSubscriptionReason = reason.startsWith('subscription_');
const recentSubscriptionLog = isSubscriptionReason
? await db.userLifecycleLog.findFirst({
where: {
userId,
reason,
createdAt: {
gte: new Date(Date.now() - 10 * 60 * 1000),
},
},
select: {
id: true,
},
})
: null;
const shouldLogLifecycleEvent =
user.lifecycleStage !== nextStage ||
(isSubscriptionReason && !recentSubscriptionLog);
if (shouldLogLifecycleEvent) {
await db.userLifecycleLog.create({
data: {
userId,
fromStage: user.lifecycleStage,
toStage: nextStage,
fitScore,
intentScore,
leadScore,
reason,
},
});
}
return updatedUser;
}
export async function getOnboardingState(userId: string) {
return db.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
plan: true,
signupSource: true,
signupSourceSelfReported: true,
signupCampaign: true,
signupLandingPath: true,
primaryUseCase: true,
primaryGoal: true,
jobRole: true,
companyName: true,
companyWebsite: true,
teamSizeBucket: true,
onboardingStartedAt: true,
sourceConfirmedAt: true,
useCaseSelectedAt: true,
goalSelectedAt: true,
profileCompletedAt: true,
firstQrCreatedAt: true,
firstDynamicQrAt: true,
firstStaticQrAt: true,
firstScanAt: true,
activationAt: true,
onboardingCompletedAt: true,
lifecycleStage: true,
fitScore: true,
intentScore: true,
leadScore: true,
},
});
}
export function getMetricSnapshot(
qrCodes: Array<{
type: 'STATIC' | 'DYNAMIC';
contentType: string;
createdAt: Date;
_count: { scans: number };
}>
): UserMetricSnapshot {
const sorted = [...qrCodes].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
const dynamicOnly = sorted.filter((qr) => qr.type === 'DYNAMIC');
const staticOnly = sorted.filter((qr) => qr.type === 'STATIC');
const businessish = sorted.filter((qr) =>
['BARCODE', 'PDF', 'VCARD', 'COUPON', 'FEEDBACK'].includes(qr.contentType)
);
return {
qrCount: sorted.length,
dynamicQrCount: dynamicOnly.length,
contentTypeCount: new Set(sorted.map((qr) => qr.contentType)).size,
businessishTypeCount: businessish.length,
scanCount: sorted.reduce((sum, qr) => sum + qr._count.scans, 0),
firstQrCreatedAt: sorted[0]?.createdAt ?? null,
firstDynamicQrAt: dynamicOnly[0]?.createdAt ?? null,
firstStaticQrAt: staticOnly[0]?.createdAt ?? null,
};
}
export function calculateFitScore(user: Pick<UserForScoring, 'email' | 'primaryUseCase' | 'primaryGoal' | 'jobRole' | 'companyName' | 'teamSizeBucket'>): number {
const emailDomain = getEmailDomain(user.email);
let score = 0;
if (emailDomain) {
score += isFreemailDomain(emailDomain) ? -15 : 20;
}
if (['marketing_campaign', 'bulk_qr', 'menu_pdf', 'barcode'].includes(user.primaryUseCase ?? '')) {
score += 10;
}
if (['track_printed_campaigns', 'generate_leads', 'manage_multiple_qr_codes'].includes(user.primaryGoal ?? '')) {
score += 10;
}
if (['founder_owner', 'marketing_manager', 'agency_freelancer', 'operations'].includes(user.jobRole ?? '')) {
score += 10;
}
if (user.companyName?.trim()) {
score += 5;
}
if (['6_20', '21_100', '100_plus'].includes(user.teamSizeBucket ?? '')) {
score += 10;
}
return score;
}
export function calculateIntentScore(input: {
firstQrCreatedAt: Date | null;
firstDynamicQrAt: Date | null;
qrCount: number;
scanCount: number;
businessishTypeCount: number;
contentTypeCount: number;
}): number {
let score = 0;
score += input.firstQrCreatedAt ? 20 : -10;
score += input.firstDynamicQrAt ? 20 : 0;
score += input.qrCount >= 3 ? 15 : 0;
score += input.scanCount > 0 ? 10 : 0;
score += input.businessishTypeCount > 0 ? 10 : 0;
score += input.contentTypeCount >= 2 ? 10 : 0;
return score;
}
export function resolveLifecycleStage(input: {
plan: string;
leadScore: number;
activationAt: Date | null;
}): LifecycleStage {
if (input.plan === 'PRO' || input.plan === 'BUSINESS') {
return 'paid';
}
if (input.leadScore >= 70) {
return 'upgrade_candidate';
}
if (input.leadScore >= 55) {
return 'hot';
}
if (input.leadScore >= 30) {
return 'warm';
}
if (input.activationAt) {
return 'activated';
}
return 'cold';
}
export function getUpgradeCandidateBadges(user: {
email?: string | null;
primaryUseCase?: string | null;
primaryGoal?: string | null;
}, metrics: {
dynamicQrCount: number;
qrCount: number;
scanCount: number;
}): string[] {
const emailDomain = getEmailDomain(user.email);
const badges: string[] = [];
if (emailDomain && !isFreemailDomain(emailDomain)) {
badges.push('business domain');
}
if (metrics.dynamicQrCount > 0) {
badges.push('dynamic usage');
}
if (metrics.qrCount >= 3) {
badges.push('3+ QRs');
}
if (metrics.scanCount > 0) {
badges.push('scans detected');
}
if (
user.primaryUseCase === 'marketing_campaign' ||
user.primaryGoal === 'track_printed_campaigns' ||
user.primaryGoal === 'generate_leads'
) {
badges.push('marketing campaign intent');
}
if (metrics.dynamicQrCount >= Math.max(1, FREE_DYNAMIC_QR_LIMIT - 1)) {
badges.push('near free plan limit');
}
return badges;
}
export function normalizeTrackedSource(source?: string | null, referrer?: string | null, landingPath?: string | null) {
return normalizeSource({
utmSource: source,
referrer,
landingPath,
});
}