BEGIN; -- 1 user columns ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "signupSource" TEXT, ADD COLUMN IF NOT EXISTS "signupSourceSelfReported" TEXT, ADD COLUMN IF NOT EXISTS "signupMedium" TEXT, ADD COLUMN IF NOT EXISTS "signupCampaign" TEXT, ADD COLUMN IF NOT EXISTS "signupContent" TEXT, ADD COLUMN IF NOT EXISTS "signupTerm" TEXT, ADD COLUMN IF NOT EXISTS "signupReferrer" TEXT, ADD COLUMN IF NOT EXISTS "signupLandingPath" TEXT, ADD COLUMN IF NOT EXISTS "signupFirstSeenAt" TIMESTAMP(3), ADD COLUMN IF NOT EXISTS "emailDomain" TEXT, ADD COLUMN IF NOT EXISTS "primaryUseCase" TEXT, ADD COLUMN IF NOT EXISTS "primaryGoal" TEXT, ADD COLUMN IF NOT EXISTS "jobRole" TEXT, ADD COLUMN IF NOT EXISTS "companyName" TEXT, ADD COLUMN IF NOT EXISTS "companyWebsite" TEXT, ADD COLUMN IF NOT EXISTS "teamSizeBucket" TEXT, ADD COLUMN IF NOT EXISTS "onboardingStartedAt" TIMESTAMP(3), ADD COLUMN IF NOT EXISTS "sourceConfirmedAt" TIMESTAMP(3), ADD COLUMN IF NOT EXISTS "useCaseSelectedAt" TIMESTAMP(3), ADD COLUMN IF NOT EXISTS "goalSelectedAt" TIMESTAMP(3), ADD COLUMN IF NOT EXISTS "profileCompletedAt" TIMESTAMP(3), ADD COLUMN IF NOT EXISTS "firstQrCreatedAt" TIMESTAMP(3), ADD COLUMN IF NOT EXISTS "firstDynamicQrAt" TIMESTAMP(3), ADD COLUMN IF NOT EXISTS "firstStaticQrAt" TIMESTAMP(3), ADD COLUMN IF NOT EXISTS "firstScanAt" TIMESTAMP(3), ADD COLUMN IF NOT EXISTS "activationAt" TIMESTAMP(3), ADD COLUMN IF NOT EXISTS "onboardingCompletedAt" TIMESTAMP(3), ADD COLUMN IF NOT EXISTS "fitScore" INTEGER NOT NULL DEFAULT 0, ADD COLUMN IF NOT EXISTS "intentScore" INTEGER NOT NULL DEFAULT 0, ADD COLUMN IF NOT EXISTS "leadScore" INTEGER NOT NULL DEFAULT 0, ADD COLUMN IF NOT EXISTS "lifecycleStage" TEXT NOT NULL DEFAULT 'cold', ADD COLUMN IF NOT EXISTS "lastQualifiedAt" TIMESTAMP(3), ADD COLUMN IF NOT EXISTS "lastScoredAt" TIMESTAMP(3); DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'User_lifecycleStage_check' ) THEN ALTER TABLE "User" ADD CONSTRAINT "User_lifecycleStage_check" CHECK ("lifecycleStage" IN ( 'cold', 'activated', 'warm', 'hot', 'upgrade_candidate', 'paid' )); END IF; END $$; -- 2 lifecycle log table CREATE TABLE IF NOT EXISTS "UserLifecycleLog" ( "id" TEXT PRIMARY KEY, "userId" TEXT NOT NULL, "fromStage" TEXT, "toStage" TEXT NOT NULL, "fitScore" INTEGER NOT NULL DEFAULT 0, "intentScore" INTEGER NOT NULL DEFAULT 0, "leadScore" INTEGER NOT NULL DEFAULT 0, "reason" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "UserLifecycleLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE ); -- 3 indexes CREATE INDEX IF NOT EXISTS "User_signupSource_idx" ON "User" ("signupSource"); CREATE INDEX IF NOT EXISTS "User_signupSourceSelfReported_idx" ON "User" ("signupSourceSelfReported"); CREATE INDEX IF NOT EXISTS "User_signupCampaign_idx" ON "User" ("signupCampaign"); CREATE INDEX IF NOT EXISTS "User_signupLandingPath_idx" ON "User" ("signupLandingPath"); CREATE INDEX IF NOT EXISTS "User_emailDomain_idx" ON "User" ("emailDomain"); CREATE INDEX IF NOT EXISTS "User_primaryUseCase_idx" ON "User" ("primaryUseCase"); CREATE INDEX IF NOT EXISTS "User_primaryGoal_idx" ON "User" ("primaryGoal"); CREATE INDEX IF NOT EXISTS "User_jobRole_idx" ON "User" ("jobRole"); CREATE INDEX IF NOT EXISTS "User_teamSizeBucket_idx" ON "User" ("teamSizeBucket"); CREATE INDEX IF NOT EXISTS "User_lifecycleStage_idx" ON "User" ("lifecycleStage"); CREATE INDEX IF NOT EXISTS "User_leadScore_idx" ON "User" ("leadScore" DESC); CREATE INDEX IF NOT EXISTS "User_activationAt_idx" ON "User" ("activationAt"); CREATE INDEX IF NOT EXISTS "User_firstScanAt_idx" ON "User" ("firstScanAt"); CREATE INDEX IF NOT EXISTS "User_firstQrCreatedAt_idx" ON "User" ("firstQrCreatedAt"); CREATE INDEX IF NOT EXISTS "User_lastQualifiedAt_idx" ON "User" ("lastQualifiedAt"); CREATE INDEX IF NOT EXISTS "UserLifecycleLog_userId_idx" ON "UserLifecycleLog" ("userId"); CREATE INDEX IF NOT EXISTS "UserLifecycleLog_toStage_idx" ON "UserLifecycleLog" ("toStage"); CREATE INDEX IF NOT EXISTS "UserLifecycleLog_createdAt_idx" ON "UserLifecycleLog" ("createdAt" DESC); -- 4 backfill UPDATE "User" SET "emailDomain" = lower(split_part("email", '@', 2)) WHERE "email" IS NOT NULL AND ("emailDomain" IS NULL OR "emailDomain" = ''); UPDATE "User" u SET "firstQrCreatedAt" = q."firstQrAt", "firstDynamicQrAt" = q."firstDynamicQrAt", "firstStaticQrAt" = q."firstStaticQrAt", "onboardingCompletedAt" = COALESCE(u."onboardingCompletedAt", q."firstQrAt") FROM ( SELECT "userId", MIN("createdAt") AS "firstQrAt", MIN("createdAt") FILTER (WHERE "type" = 'DYNAMIC') AS "firstDynamicQrAt", MIN("createdAt") FILTER (WHERE "type" = 'STATIC') AS "firstStaticQrAt" FROM "QRCode" GROUP BY "userId" ) q WHERE u."id" = q."userId"; UPDATE "User" u SET "firstScanAt" = s."firstScanAt", "activationAt" = COALESCE(u."activationAt", s."firstScanAt") FROM ( SELECT q."userId", MIN(s."ts") AS "firstScanAt" FROM "QRCode" q INNER JOIN "QRScan" s ON s."qrId" = q."id" GROUP BY q."userId" ) s WHERE u."id" = s."userId"; -- 5 scoring WITH qr_stats AS ( SELECT q."userId", COUNT(*) AS qr_count, COUNT(*) FILTER (WHERE q."type" = 'DYNAMIC') AS dynamic_count, COUNT(DISTINCT q."contentType") AS content_type_count, COUNT(*) FILTER (WHERE q."contentType" IN ('BARCODE','PDF','VCARD','COUPON','FEEDBACK')) AS businessish_type_count FROM "QRCode" q GROUP BY q."userId" ), scan_stats AS ( SELECT q."userId", COUNT(s."id") AS scan_count FROM "QRCode" q LEFT JOIN "QRScan" s ON s."qrId" = q."id" GROUP BY q."userId" ), scored AS ( SELECT u."id", COALESCE(qs.qr_count, 0) AS qr_count, COALESCE(qs.dynamic_count, 0) AS dynamic_count, COALESCE(qs.content_type_count, 0) AS content_type_count, COALESCE(qs.businessish_type_count, 0) AS businessish_type_count, COALESCE(ss.scan_count, 0) AS scan_count FROM "User" u LEFT JOIN qr_stats qs ON qs."userId" = u."id" LEFT JOIN scan_stats ss ON ss."userId" = u."id" ) UPDATE "User" u SET "fitScore" = (CASE WHEN lower(split_part(u."email", '@', 2)) IN ('gmail.com','yahoo.com','hotmail.com','outlook.com','icloud.com') THEN -15 WHEN u."email" IS NOT NULL THEN 20 ELSE 0 END) + (CASE WHEN u."primaryUseCase" IN ('marketing_campaign','bulk_qr','menu_pdf','barcode') THEN 10 ELSE 0 END) + (CASE WHEN u."primaryGoal" IN ('track_printed_campaigns','generate_leads','manage_multiple_qr_codes') THEN 10 ELSE 0 END) + (CASE WHEN u."jobRole" IN ('founder_owner','marketing_manager','agency_freelancer','operations') THEN 10 ELSE 0 END) + (CASE WHEN u."companyName" IS NOT NULL AND u."companyName" <> '' THEN 5 ELSE 0 END) + (CASE WHEN u."teamSizeBucket" IN ('6_20','21_100','100_plus') THEN 10 ELSE 0 END), "intentScore" = (CASE WHEN u."firstQrCreatedAt" IS NOT NULL THEN 20 ELSE -10 END) + (CASE WHEN u."firstDynamicQrAt" IS NOT NULL THEN 20 ELSE 0 END) + (CASE WHEN COALESCE(s.qr_count, 0) >= 3 THEN 15 ELSE 0 END) + (CASE WHEN COALESCE(s.scan_count, 0) > 0 THEN 10 ELSE 0 END) + (CASE WHEN COALESCE(s.businessish_type_count, 0) > 0 THEN 10 ELSE 0 END) + (CASE WHEN COALESCE(s.content_type_count, 0) >= 2 THEN 10 ELSE 0 END), "lastScoredAt" = CURRENT_TIMESTAMP FROM scored s WHERE u."id" = s."id"; UPDATE "User" SET "leadScore" = COALESCE("fitScore", 0) + COALESCE("intentScore", 0); UPDATE "User" SET "lifecycleStage" = CASE WHEN "plan" IN ('PRO','BUSINESS') THEN 'paid' WHEN "leadScore" >= 70 THEN 'upgrade_candidate' WHEN "leadScore" >= 55 THEN 'hot' WHEN "leadScore" >= 30 THEN 'warm' WHEN "activationAt" IS NOT NULL THEN 'activated' ELSE 'cold' END, "lastQualifiedAt" = CASE WHEN "leadScore" >= 55 OR "plan" IN ('PRO','BUSINESS') THEN CURRENT_TIMESTAMP ELSE "lastQualifiedAt" END; -- 6 reporting queries -- Acquisition overview SELECT COALESCE("signupSource", 'unknown') AS source, COUNT(*) AS signups, COUNT(*) FILTER (WHERE "firstQrCreatedAt" IS NOT NULL) AS first_qr, COUNT(*) FILTER (WHERE "activationAt" IS NOT NULL) AS activated, COUNT(*) FILTER (WHERE "lifecycleStage" = 'hot') AS hot, COUNT(*) FILTER (WHERE "lifecycleStage" = 'upgrade_candidate') AS upgrade_candidates, COUNT(*) FILTER (WHERE "lifecycleStage" = 'paid') AS paid FROM "User" GROUP BY 1 ORDER BY signups DESC; -- Onboarding funnel SELECT COUNT(*) AS signup, COUNT(*) FILTER (WHERE "sourceConfirmedAt" IS NOT NULL) AS source_confirmed, COUNT(*) FILTER (WHERE "useCaseSelectedAt" IS NOT NULL) AS use_case_selected, COUNT(*) FILTER (WHERE "goalSelectedAt" IS NOT NULL) AS goal_selected, COUNT(*) FILTER (WHERE "profileCompletedAt" IS NOT NULL) AS profile_completed, COUNT(*) FILTER (WHERE "firstQrCreatedAt" IS NOT NULL) AS first_qr_created, COUNT(*) FILTER (WHERE "firstDynamicQrAt" IS NOT NULL) AS first_dynamic_qr_created, COUNT(*) FILTER (WHERE "activationAt" IS NOT NULL) AS activated FROM "User"; -- Lifecycle summary SELECT "lifecycleStage", COUNT(*) AS users FROM "User" GROUP BY 1 ORDER BY users DESC; COMMIT;