diff --git a/sql/revops_onboarding_v1.sql b/sql/revops_onboarding_v1.sql new file mode 100644 index 0000000..5551fdf --- /dev/null +++ b/sql/revops_onboarding_v1.sql @@ -0,0 +1,263 @@ +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;