Add revops onboarding SQL migration
This commit is contained in:
263
sql/revops_onboarding_v1.sql
Normal file
263
sql/revops_onboarding_v1.sql
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user