revops + onboarding
This commit is contained in:
361
src/lib/revops-server.ts
Normal file
361
src/lib/revops-server.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
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';
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
if (user.lifecycleStage !== nextStage) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user