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): 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, }); }