From 24368af4f787c1d40d8e6ffb846badaaf6f498b5 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 15 Jul 2026 13:06:55 +0200 Subject: [PATCH 1/3] fix(admin): add activation stages to onboarding funnel --- ...dmin-onboarding-activation-milestones.webp | Bin 22746 -> 18486 bytes messages/en.json | 6 +- src/pages/admin/dashboard/users.vue | 89 +++++++++--------- .../_backend/utils/onboardingFunnel.ts | 19 ++-- supabase/functions/_backend/utils/pg.ts | 8 +- tests/admin-onboarding-funnel.unit.test.ts | 27 ++++-- 6 files changed, 85 insertions(+), 64 deletions(-) diff --git a/docs/pr-screenshots/admin-onboarding-activation-milestones.webp b/docs/pr-screenshots/admin-onboarding-activation-milestones.webp index d9a28ba7ff88a33075ee012871895abed82c85a1..4d655896055f50f353c2df85b4c76093228ff80e 100644 GIT binary patch literal 18486 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 diff --git a/messages/en.json b/messages/en.json index 4eeed81322..9a6a80c16d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -259,11 +259,11 @@ "admin-debug-description": "Replicate your bundle across our global infrastructure so updates are available faster, and verify the rollout toast flow you see in production.", "admin-debug-hint": "This runs the exact same toast lifecycle as production: live progress while the update replicates in each region, then completion once all regions are updated.", "admin-debug-trigger-toast": "Run replication toast simulation", - "activation-signals": "Activation signals", "activation-telemetry-unavailable": "Activation telemetry is unavailable for this date range. Select a range within the last 3 months.", + "bundle-to-production-device": "Bundle → Production device", "completed-update-download": "Completed update download", "completed-update-download-within-7-days": "Completed Update Download (within 7 days)", - "of-new-organizations": "of new organizations", + "production-device-to-update-download": "Production device → Update download", "production-plugin-device": "Production plugin device", "production-plugin-device-within-7-days": "Production Plugin Device (within 7 days)", "all-billing-cycles": "All billing cycles", @@ -1647,7 +1647,7 @@ "onboarding-demo-option-title": "Just want to explore Capgo?", "onboarding-demo-success": "Demo app created! Welcome to Capgo! This demo will be deleted in 14 days.", "onboarding-funnel": "Onboarding Funnel", - "onboarding-funnel-description": "Track user progression from organization creation to first bundle upload", + "onboarding-funnel-description": "Track organization activation from creation through the first completed update download", "onboarding-invite-option-cta": "Invite a technical teammate", "onboarding-invite-option-dialog-desc": "We will email them detailed instructions to create the first app for this organization.", "onboarding-invite-option-helper": "They will receive an email asking them to create the first app for this organization.", diff --git a/src/pages/admin/dashboard/users.vue b/src/pages/admin/dashboard/users.vue index aed0c5700b..47d1a9a012 100644 --- a/src/pages/admin/dashboard/users.vue +++ b/src/pages/admin/dashboard/users.vue @@ -767,8 +767,8 @@ const onboardingFunnelRates = computed(() => { channel: orgsWithApp > 0 ? (orgsWithChannel / orgsWithApp) * 100 : 0, bundle: orgsWithChannel > 0 ? (orgsWithBundle / orgsWithChannel) * 100 : 0, subscribed: orgsWithBundle > 0 ? (orgsSubscribed / orgsWithBundle) * 100 : 0, - productionDevice: totalOrgs > 0 ? (orgsWithProductionDevice / totalOrgs) * 100 : 0, - updateDownload: totalOrgs > 0 ? (orgsWithUpdateDownload / totalOrgs) * 100 : 0, + productionDevice: orgsWithBundle > 0 ? (orgsWithProductionDevice / orgsWithBundle) * 100 : 0, + updateDownload: orgsWithProductionDevice > 0 ? (orgsWithUpdateDownload / orgsWithProductionDevice) * 100 : 0, } }) @@ -804,12 +804,22 @@ const onboardingFunnelStages = computed(() => { percentage: rates.bundle, color: '#10b981', // green }, - { - label: t('subscribed'), - value: Number(data.orgs_subscribed) || 0, - percentage: rates.subscribed, - color: '#ef4444', // red - }, + ...(data.activation_telemetry_available + ? [ + { + label: t('production-plugin-device'), + value: Number(data.orgs_with_production_device) || 0, + percentage: rates.productionDevice, + color: '#ec4899', // pink + }, + { + label: t('completed-update-download'), + value: Number(data.orgs_with_update_download) || 0, + percentage: rates.updateDownload, + color: '#6366f1', // indigo + }, + ] + : []), ] }) @@ -964,12 +974,12 @@ displayStore.defaultBack = '/dashboard'
-
+
- -
+ +

{{ formatOneDecimal(onboardingFunnelRates.app) }}% @@ -994,47 +1004,38 @@ displayStore.defaultBack = '/dashboard' {{ t('channel-to-bundle') }}

-
-

- {{ formatOneDecimal(onboardingFunnelRates.subscribed) }}% +

+

+ {{ formatOneDecimal(onboardingFunnelRates.productionDevice) }}%

- {{ t('bundle-to-subscribed') }} + {{ t('bundle-to-production-device') }} +

+
+
+

+ {{ formatOneDecimal(onboardingFunnelRates.updateDownload) }}% +

+

+ {{ t('production-device-to-update-download') }}

+

+ {{ t('activation-telemetry-unavailable') }} +

+ +
-

- {{ t('activation-signals') }} -

-
-
-

- {{ formatNumberValue(onboardingFunnelData?.orgs_with_production_device || 0) }} -

-

- {{ t('production-plugin-device') }} -

-

- {{ formatOneDecimal(onboardingFunnelRates.productionDevice) }}% {{ t('of-new-organizations') }} -

-
-
-

- {{ formatNumberValue(onboardingFunnelData?.orgs_with_update_download || 0) }} -

-

- {{ t('completed-update-download') }} -

-

- {{ formatOneDecimal(onboardingFunnelRates.updateDownload) }}% {{ t('of-new-organizations') }} -

-
+
+

+ {{ formatOneDecimal(onboardingFunnelRates.subscribed) }}% +

+

+ {{ t('bundle-to-subscribed') }} +

-

- {{ t('activation-telemetry-unavailable') }} -

diff --git a/supabase/functions/_backend/utils/onboardingFunnel.ts b/supabase/functions/_backend/utils/onboardingFunnel.ts index 39fcb0ca05..9d3c75daaf 100644 --- a/supabase/functions/_backend/utils/onboardingFunnel.ts +++ b/supabase/functions/_backend/utils/onboardingFunnel.ts @@ -50,19 +50,20 @@ export function getAdminOnboardingActivationMetrics( const date = startAt.toISOString().slice(0, 10) const productionDeviceAt = telemetry.first_production_device_at_by_app.get(cohort.app_id) - if (isWithinActivationWindow(productionDeviceAt, startAt, endAt)) { - productionDeviceOrgIds.add(cohort.org_id) - const orgIds = productionDeviceOrgIdsByDate.get(date) ?? new Set() - orgIds.add(cohort.org_id) - productionDeviceOrgIdsByDate.set(date, orgIds) - } + if (!isWithinActivationWindow(productionDeviceAt, startAt, endAt)) + continue + + productionDeviceOrgIds.add(cohort.org_id) + const productionDeviceOrgIdsForDate = productionDeviceOrgIdsByDate.get(date) ?? new Set() + productionDeviceOrgIdsForDate.add(cohort.org_id) + productionDeviceOrgIdsByDate.set(date, productionDeviceOrgIdsForDate) const updateDownloadAt = telemetry.first_update_download_at_by_app.get(cohort.app_id) if (isWithinActivationWindow(updateDownloadAt, startAt, endAt)) { updateDownloadOrgIds.add(cohort.org_id) - const orgIds = updateDownloadOrgIdsByDate.get(date) ?? new Set() - orgIds.add(cohort.org_id) - updateDownloadOrgIdsByDate.set(date, orgIds) + const updateDownloadOrgIdsForDate = updateDownloadOrgIdsByDate.get(date) ?? new Set() + updateDownloadOrgIdsForDate.add(cohort.org_id) + updateDownloadOrgIdsByDate.set(date, updateDownloadOrgIdsForDate) } } diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index d6e1410766..70a281052a 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -3282,10 +3282,14 @@ export async function getAdminOnboardingFunnel( a.app_id as app_id FROM orgs o INNER JOIN apps a ON a.owner_org = o.id + INNER JOIN channels ch ON ch.app_id = a.app_id + INNER JOIN app_versions av ON av.id = ch.version AND av.name NOT IN ('builtin', 'unknown') WHERE o.created_at >= ${start_date}::timestamp AND o.created_at < ${end_date}::timestamp AND a.created_at >= o.created_at AND a.created_at < o.created_at + interval '7 days' + AND av.created_at >= o.created_at + AND av.created_at < o.created_at + interval '7 days' ` const [trendResult, activationCohortResult] = await Promise.all([ @@ -3349,8 +3353,8 @@ export async function getAdminOnboardingFunnel( channel_conversion_rate: orgsWithApp > 0 ? (orgsWithChannel / orgsWithApp) * 100 : 0, bundle_conversion_rate: orgsWithChannel > 0 ? (orgsWithBundle / orgsWithChannel) * 100 : 0, subscription_conversion_rate: orgsWithBundle > 0 ? (orgsSubscribed / orgsWithBundle) * 100 : 0, - production_device_conversion_rate: totalOrgs > 0 ? (activationMetrics.orgs_with_production_device / totalOrgs) * 100 : 0, - update_download_conversion_rate: totalOrgs > 0 ? (activationMetrics.orgs_with_update_download / totalOrgs) * 100 : 0, + production_device_conversion_rate: orgsWithBundle > 0 ? (activationMetrics.orgs_with_production_device / orgsWithBundle) * 100 : 0, + update_download_conversion_rate: activationMetrics.orgs_with_production_device > 0 ? (activationMetrics.orgs_with_update_download / activationMetrics.orgs_with_production_device) * 100 : 0, trend, } diff --git a/tests/admin-onboarding-funnel.unit.test.ts b/tests/admin-onboarding-funnel.unit.test.ts index 8683f1fa48..11ac91d68d 100644 --- a/tests/admin-onboarding-funnel.unit.test.ts +++ b/tests/admin-onboarding-funnel.unit.test.ts @@ -96,7 +96,7 @@ describe('admin onboarding activation telemetry', () => { } }) - it.concurrent('counts organization activation signals once within each seven-day window', () => { + it.concurrent('counts nested activation funnel stages once within each seven-day window', () => { const metrics = getAdminOnboardingActivationMetrics([ { org_id: 'org-alpha', @@ -116,29 +116,44 @@ describe('admin onboarding activation telemetry', () => { created_at: '2026-07-02T00:00:00.000Z', activation_window_end: '2026-07-09T00:00:00.000Z', }, + { + org_id: 'org-gamma', + app_id: 'app-gamma-device', + created_at: '2026-07-03T00:00:00.000Z', + activation_window_end: '2026-07-10T00:00:00.000Z', + }, + { + org_id: 'org-gamma', + app_id: 'app-gamma-download', + created_at: '2026-07-03T00:00:00.000Z', + activation_window_end: '2026-07-10T00:00:00.000Z', + }, ], { available: true, first_production_device_at_by_app: new Map([ ['app-alpha', new Date('2026-07-02T00:00:00.000Z')], ['app-alpha-second', new Date('2026-07-03T00:00:00.000Z')], ['app-beta', new Date('2026-07-09T00:00:00.000Z')], + ['app-gamma-device', new Date('2026-07-04T00:00:00.000Z')], ]), first_update_download_at_by_app: new Map([ ['app-alpha', new Date('2026-07-04T00:00:00.000Z')], ['app-alpha-second', new Date('2026-07-10T00:00:00.000Z')], ['app-beta', new Date('2026-07-08T00:00:00.000Z')], + ['app-gamma-download', new Date('2026-07-05T00:00:00.000Z')], ]), }) - expect(metrics.orgs_with_production_device).toBe(1) - expect(metrics.orgs_with_update_download).toBe(2) + expect(metrics.orgs_with_production_device).toBe(2) + expect(metrics.orgs_with_update_download).toBe(1) expect(metrics.trend_by_date.get('2026-07-01')).toEqual({ orgs_with_production_device: 1, orgs_with_update_download: 1, }) - expect(metrics.trend_by_date.get('2026-07-02')).toEqual({ - orgs_with_production_device: 0, - orgs_with_update_download: 1, + expect(metrics.trend_by_date.get('2026-07-02')).toBeUndefined() + expect(metrics.trend_by_date.get('2026-07-03')).toEqual({ + orgs_with_production_device: 1, + orgs_with_update_download: 0, }) }) From e2dec0c615f79181fd875ba2221e9a55ac7407ee Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 15 Jul 2026 13:29:41 +0200 Subject: [PATCH 2/3] fix(admin): preserve onboarding bundle history --- supabase/functions/_backend/utils/pg.ts | 68 +++++++++++++++++++------ tests/admin-stats.test.ts | 61 ++++++++++++++++++++++ 2 files changed, 113 insertions(+), 16 deletions(-) diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 70a281052a..1f6a7ba529 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -3164,10 +3164,19 @@ export async function getAdminOnboardingFunnel( SELECT DISTINCT o.id, o.customer_id, o.created_at, o.created_date FROM orgs_in_range o INNER JOIN apps a ON a.owner_org = o.id - INNER JOIN channels c ON c.app_id = a.app_id - INNER JOIN app_versions av ON av.id = c.version AND av.name NOT IN ('builtin', 'unknown') - WHERE av.created_at >= o.created_at - AND av.created_at < o.created_at + interval '7 days' + WHERE EXISTS ( + SELECT 1 + FROM channels c + WHERE c.app_id = a.app_id + ) + AND EXISTS ( + SELECT 1 + FROM app_versions av + WHERE av.app_id = a.app_id + AND av.name NOT IN ('builtin', 'unknown') + AND av.created_at >= o.created_at + AND av.created_at < o.created_at + interval '7 days' + ) ), orgs_subscribed AS ( SELECT DISTINCT o.id, o.created_date @@ -3235,25 +3244,43 @@ export async function getAdminOnboardingFunnel( SELECT o.created_at::date as date, COUNT(DISTINCT o.id)::int as orgs_created_bundle FROM orgs o INNER JOIN apps a ON a.owner_org = o.id - INNER JOIN channels c ON c.app_id = a.app_id - INNER JOIN app_versions av ON av.id = c.version AND av.name NOT IN ('builtin', 'unknown') WHERE o.created_at >= ${start_date}::timestamp AND o.created_at < ${end_date}::timestamp - AND av.created_at >= o.created_at - AND av.created_at < o.created_at + interval '7 days' + AND EXISTS ( + SELECT 1 + FROM channels c + WHERE c.app_id = a.app_id + ) + AND EXISTS ( + SELECT 1 + FROM app_versions av + WHERE av.app_id = a.app_id + AND av.name NOT IN ('builtin', 'unknown') + AND av.created_at >= o.created_at + AND av.created_at < o.created_at + interval '7 days' + ) GROUP BY o.created_at::date ), daily_subscriptions AS ( SELECT o.created_at::date as date, COUNT(DISTINCT o.id)::int as orgs_subscribed FROM orgs o INNER JOIN apps a ON a.owner_org = o.id - INNER JOIN channels c ON c.app_id = a.app_id - INNER JOIN app_versions av ON av.id = c.version AND av.name NOT IN ('builtin', 'unknown') INNER JOIN stripe_info si ON si.customer_id = o.customer_id WHERE o.created_at >= ${start_date}::timestamp AND o.created_at < ${end_date}::timestamp - AND av.created_at >= o.created_at - AND av.created_at < o.created_at + interval '7 days' + AND EXISTS ( + SELECT 1 + FROM channels c + WHERE c.app_id = a.app_id + ) + AND EXISTS ( + SELECT 1 + FROM app_versions av + WHERE av.app_id = a.app_id + AND av.name NOT IN ('builtin', 'unknown') + AND av.created_at >= o.created_at + AND av.created_at < o.created_at + interval '7 days' + ) AND si.paid_at IS NOT NULL AND si.paid_at >= o.created_at AND si.paid_at < o.created_at + interval '7 days' @@ -3282,14 +3309,23 @@ export async function getAdminOnboardingFunnel( a.app_id as app_id FROM orgs o INNER JOIN apps a ON a.owner_org = o.id - INNER JOIN channels ch ON ch.app_id = a.app_id - INNER JOIN app_versions av ON av.id = ch.version AND av.name NOT IN ('builtin', 'unknown') WHERE o.created_at >= ${start_date}::timestamp AND o.created_at < ${end_date}::timestamp AND a.created_at >= o.created_at AND a.created_at < o.created_at + interval '7 days' - AND av.created_at >= o.created_at - AND av.created_at < o.created_at + interval '7 days' + AND EXISTS ( + SELECT 1 + FROM channels ch + WHERE ch.app_id = a.app_id + ) + AND EXISTS ( + SELECT 1 + FROM app_versions av + WHERE av.app_id = a.app_id + AND av.name NOT IN ('builtin', 'unknown') + AND av.created_at >= o.created_at + AND av.created_at < o.created_at + interval '7 days' + ) ` const [trendResult, activationCohortResult] = await Promise.all([ diff --git a/tests/admin-stats.test.ts b/tests/admin-stats.test.ts index 6d2bc71609..6f4152a20b 100644 --- a/tests/admin-stats.test.ts +++ b/tests/admin-stats.test.ts @@ -989,6 +989,67 @@ describe('/private/admin_stats', () => { }) }) + it('keeps an uploaded bundle in the funnel after a later channel promotion', async () => { + const supabase = getSupabaseClient() + const { data: promotedVersion, error: promotedVersionError } = await supabase + .from('app_versions') + .insert({ + app_id: ONBOARDING_APP_ID, + name: '2.0.0', + owner_org: ONBOARDING_ORG_ID, + user_id: USER_ID, + storage_provider: 'r2-direct', + created_at: '2026-02-12T10:00:00.000Z', + }) + .select('id') + .single() + if (promotedVersionError) + throw promotedVersionError + if (!promotedVersion) + throw new Error('Expected a later onboarding bundle to be created') + + const { error: channelUpdateError } = await supabase + .from('channels') + .update({ version: promotedVersion.id }) + .eq('app_id', ONBOARDING_APP_ID) + .eq('name', 'production') + if (channelUpdateError) + throw channelUpdateError + + const response = await fetchTestRequest(`${BASE_URL}/private/admin_stats`, { + method: 'POST', + headers: adminHeaders, + body: JSON.stringify({ + metric_category: 'onboarding_funnel', + start_date: '2026-02-01T00:00:00.000Z', + end_date: '2026-02-02T00:00:00.000Z', + }), + }) + + expect(response.status).toBe(200) + const payload = await response.json() as { + success: boolean + data: { + orgs_with_bundle: number + orgs_subscribed: number + trend: Array<{ + date: string + orgs_created_bundle: number + orgs_subscribed: number + }> + } + } + + expect(payload.success).toBe(true) + expect(payload.data.orgs_with_bundle).toBe(2) + expect(payload.data.orgs_subscribed).toBe(1) + expect(payload.data.trend[0]).toMatchObject({ + date: '2026-02-01', + orgs_created_bundle: 2, + orgs_subscribed: 1, + }) + }) + it('returns daily new trial organizations grouped by plan', async () => { const response = await fetchTestRequest(getEndpointUrl('/private/admin_stats'), { method: 'POST', From 579fbb2fe30fec67e0e06c6ec039e4c9b13d10d6 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 15 Jul 2026 13:46:51 +0200 Subject: [PATCH 3/3] fix(admin): align onboarding funnel cohorts --- supabase/functions/_backend/utils/pg.ts | 76 +++------ tests/admin-stats.test.ts | 195 +++++++++++++++++------- 2 files changed, 164 insertions(+), 107 deletions(-) diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 1f6a7ba529..2432ccdd33 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -3141,6 +3141,24 @@ export async function getAdminOnboardingFunnel( const drizzleClient = getDrizzleClient(pgClient) const now = new Date() + const onboardingBundleEligibility = sql` + a.created_at >= o.created_at + AND a.created_at < o.created_at + interval '7 days' + AND EXISTS ( + SELECT 1 + FROM channels c + WHERE c.app_id = a.app_id + ) + AND EXISTS ( + SELECT 1 + FROM app_versions av + WHERE av.app_id = a.app_id + AND av.name NOT IN ('builtin', 'unknown') + AND av.created_at >= o.created_at + AND av.created_at < o.created_at + interval '7 days' + ) + ` + // Get total funnel counts for orgs created in the date range const funnelQuery = sql` WITH orgs_in_range AS ( @@ -3164,19 +3182,7 @@ export async function getAdminOnboardingFunnel( SELECT DISTINCT o.id, o.customer_id, o.created_at, o.created_date FROM orgs_in_range o INNER JOIN apps a ON a.owner_org = o.id - WHERE EXISTS ( - SELECT 1 - FROM channels c - WHERE c.app_id = a.app_id - ) - AND EXISTS ( - SELECT 1 - FROM app_versions av - WHERE av.app_id = a.app_id - AND av.name NOT IN ('builtin', 'unknown') - AND av.created_at >= o.created_at - AND av.created_at < o.created_at + interval '7 days' - ) + WHERE ${onboardingBundleEligibility} ), orgs_subscribed AS ( SELECT DISTINCT o.id, o.created_date @@ -3246,19 +3252,7 @@ export async function getAdminOnboardingFunnel( INNER JOIN apps a ON a.owner_org = o.id WHERE o.created_at >= ${start_date}::timestamp AND o.created_at < ${end_date}::timestamp - AND EXISTS ( - SELECT 1 - FROM channels c - WHERE c.app_id = a.app_id - ) - AND EXISTS ( - SELECT 1 - FROM app_versions av - WHERE av.app_id = a.app_id - AND av.name NOT IN ('builtin', 'unknown') - AND av.created_at >= o.created_at - AND av.created_at < o.created_at + interval '7 days' - ) + AND ${onboardingBundleEligibility} GROUP BY o.created_at::date ), daily_subscriptions AS ( @@ -3268,19 +3262,7 @@ export async function getAdminOnboardingFunnel( INNER JOIN stripe_info si ON si.customer_id = o.customer_id WHERE o.created_at >= ${start_date}::timestamp AND o.created_at < ${end_date}::timestamp - AND EXISTS ( - SELECT 1 - FROM channels c - WHERE c.app_id = a.app_id - ) - AND EXISTS ( - SELECT 1 - FROM app_versions av - WHERE av.app_id = a.app_id - AND av.name NOT IN ('builtin', 'unknown') - AND av.created_at >= o.created_at - AND av.created_at < o.created_at + interval '7 days' - ) + AND ${onboardingBundleEligibility} AND si.paid_at IS NOT NULL AND si.paid_at >= o.created_at AND si.paid_at < o.created_at + interval '7 days' @@ -3311,21 +3293,7 @@ export async function getAdminOnboardingFunnel( INNER JOIN apps a ON a.owner_org = o.id WHERE o.created_at >= ${start_date}::timestamp AND o.created_at < ${end_date}::timestamp - AND a.created_at >= o.created_at - AND a.created_at < o.created_at + interval '7 days' - AND EXISTS ( - SELECT 1 - FROM channels ch - WHERE ch.app_id = a.app_id - ) - AND EXISTS ( - SELECT 1 - FROM app_versions av - WHERE av.app_id = a.app_id - AND av.name NOT IN ('builtin', 'unknown') - AND av.created_at >= o.created_at - AND av.created_at < o.created_at + interval '7 days' - ) + AND ${onboardingBundleEligibility} ` const [trendResult, activationCohortResult] = await Promise.all([ diff --git a/tests/admin-stats.test.ts b/tests/admin-stats.test.ts index 6f4152a20b..c45b4b51de 100644 --- a/tests/admin-stats.test.ts +++ b/tests/admin-stats.test.ts @@ -989,65 +989,154 @@ describe('/private/admin_stats', () => { }) }) - it('keeps an uploaded bundle in the funnel after a later channel promotion', async () => { + it.concurrent('keeps an uploaded bundle in the funnel after a later channel promotion', async () => { + if (!soloPlan) + throw new Error('Expected Solo plan to exist for onboarding funnel test') + const supabase = getSupabaseClient() - const { data: promotedVersion, error: promotedVersionError } = await supabase - .from('app_versions') - .insert({ - app_id: ONBOARDING_APP_ID, - name: '2.0.0', - owner_org: ONBOARDING_ORG_ID, - user_id: USER_ID, - storage_provider: 'r2-direct', - created_at: '2026-02-12T10:00:00.000Z', - }) - .select('id') - .single() - if (promotedVersionError) - throw promotedVersionError - if (!promotedVersion) - throw new Error('Expected a later onboarding bundle to be created') - - const { error: channelUpdateError } = await supabase - .from('channels') - .update({ version: promotedVersion.id }) - .eq('app_id', ONBOARDING_APP_ID) - .eq('name', 'production') - if (channelUpdateError) - throw channelUpdateError + const orgId = randomUUID() + const suffix = orgId.replaceAll('-', '').slice(0, 12) + const appId = `com.admin.stats.onboardinghistory.${suffix}` + const customerId = `cus_admin_stats_onboarding_history_${suffix}` + const orgCreatedAt = '2098-07-01T10:00:00.000Z' - const response = await fetchTestRequest(`${BASE_URL}/private/admin_stats`, { - method: 'POST', - headers: adminHeaders, - body: JSON.stringify({ - metric_category: 'onboarding_funnel', - start_date: '2026-02-01T00:00:00.000Z', - end_date: '2026-02-02T00:00:00.000Z', - }), - }) + try { + const { error: stripeError } = await supabase.from('stripe_info').insert({ + customer_id: customerId, + status: 'succeeded', + product_id: soloPlan.stripe_id, + price_id: soloPlan.price_m_id, + trial_at: '2098-07-20T10:00:00.000Z', + paid_at: '2098-07-05T10:00:00.000Z', + is_good_plan: true, + plan_usage: 2, + subscription_anchor_start: '2098-07-05T10:00:00.000Z', + subscription_anchor_end: '2098-08-05T10:00:00.000Z', + }) + if (stripeError) + throw stripeError + + const { error: orgError } = await supabase.from('orgs').insert({ + id: orgId, + name: `Admin Stats Onboarding History ${suffix}`, + created_by: USER_ID, + management_email: TEST_EMAIL, + customer_id: customerId, + created_at: orgCreatedAt, + }) + if (orgError) + throw orgError + + const { error: appError } = await supabase.from('apps').insert({ + owner_org: orgId, + name: 'Admin Stats Onboarding History App', + app_id: appId, + icon_url: 'https://example.com/icon.png', + created_at: '2098-07-02T10:00:00.000Z', + }) + if (appError) + throw appError + + const { data: firstVersion, error: firstVersionError } = await supabase + .from('app_versions') + .insert({ + app_id: appId, + name: '1.0.0', + owner_org: orgId, + user_id: USER_ID, + storage_provider: 'r2-direct', + created_at: '2098-07-04T10:00:00.000Z', + }) + .select('id') + .single() + if (firstVersionError) + throw firstVersionError + if (!firstVersion) + throw new Error('Expected the initial onboarding bundle to be created') + + const { error: channelError } = await supabase.from('channels').insert({ + name: 'production', + app_id: appId, + version: firstVersion.id, + created_by: USER_ID, + owner_org: orgId, + created_at: '2098-07-03T10:00:00.000Z', + }) + if (channelError) + throw channelError + + const { data: promotedVersion, error: promotedVersionError } = await supabase + .from('app_versions') + .insert({ + app_id: appId, + name: '2.0.0', + owner_org: orgId, + user_id: USER_ID, + storage_provider: 'r2-direct', + created_at: '2098-07-12T10:00:00.000Z', + }) + .select('id') + .single() + if (promotedVersionError) + throw promotedVersionError + if (!promotedVersion) + throw new Error('Expected a later onboarding bundle to be created') + + const { error: channelUpdateError } = await supabase + .from('channels') + .update({ version: promotedVersion.id }) + .eq('app_id', appId) + .eq('name', 'production') + if (channelUpdateError) + throw channelUpdateError + + const response = await fetchTestRequest(`${BASE_URL}/private/admin_stats`, { + method: 'POST', + headers: adminHeaders, + body: JSON.stringify({ + metric_category: 'onboarding_funnel', + start_date: '2098-07-01T00:00:00.000Z', + end_date: '2098-07-02T00:00:00.000Z', + }), + }) - expect(response.status).toBe(200) - const payload = await response.json() as { - success: boolean - data: { - orgs_with_bundle: number - orgs_subscribed: number - trend: Array<{ - date: string - orgs_created_bundle: number + expect(response.status).toBe(200) + const payload = await response.json() as { + success: boolean + data: { + total_orgs: number + orgs_with_app: number + orgs_with_channel: number + orgs_with_bundle: number orgs_subscribed: number - }> + trend: Array<{ + date: string + orgs_created_bundle: number + orgs_subscribed: number + }> + } } - } - expect(payload.success).toBe(true) - expect(payload.data.orgs_with_bundle).toBe(2) - expect(payload.data.orgs_subscribed).toBe(1) - expect(payload.data.trend[0]).toMatchObject({ - date: '2026-02-01', - orgs_created_bundle: 2, - orgs_subscribed: 1, - }) + expect(payload.success).toBe(true) + expect(payload.data.total_orgs).toBe(1) + expect(payload.data.orgs_with_app).toBe(1) + expect(payload.data.orgs_with_channel).toBe(1) + expect(payload.data.orgs_with_bundle).toBe(1) + expect(payload.data.orgs_subscribed).toBe(1) + expect(payload.data.trend).toHaveLength(1) + expect(payload.data.trend[0]).toMatchObject({ + date: '2098-07-01', + orgs_created_bundle: 1, + orgs_subscribed: 1, + }) + } + finally { + await supabase.from('channels').delete().eq('app_id', appId) + await supabase.from('app_versions').delete().eq('app_id', appId) + await supabase.from('apps').delete().eq('app_id', appId) + await supabase.from('orgs').delete().eq('id', orgId) + await supabase.from('stripe_info').delete().eq('customer_id', customerId) + } }) it('returns daily new trial organizations grouped by plan', async () => {