From d6e77bbed9e9dc30b152811a001b4f20e0895b4d Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sun, 25 Jan 2026 01:45:57 +0000 Subject: [PATCH 1/4] feat(admin): add onboarding funnel chart to users dashboard Adds a comprehensive onboarding funnel visualization to the admin dashboard that tracks user progression from organization creation through app creation, channel creation, and bundle upload. Includes both funnel visualization with conversion rates and daily trend charts. Co-Authored-By: Claude Haiku 4.5 --- messages/en.json | 4 + src/pages/admin/dashboard/users.vue | 196 +++++++++++++++++- src/stores/adminDashboard.ts | 2 +- .../functions/_backend/private/admin_stats.ts | 8 +- supabase/functions/_backend/utils/pg.ts | 172 +++++++++++++++ 5 files changed, 378 insertions(+), 4 deletions(-) diff --git a/messages/en.json b/messages/en.json index e0e1e63020..38aa6ffc7c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -205,6 +205,10 @@ "new-users": "New Users", "plan-distribution": "Plan Distribution", "plan-distribution-trend": "Plan Distribution Trend", + "onboarding-funnel": "Onboarding Funnel", + "onboarding-funnel-description": "Track user progression from organization creation to first bundle upload", + "onboarding-trend": "Onboarding Trend", + "no-data-available": "No data available", "users-trend": "Users Trend", "users-activity-trend": "Users Activity Trend", "registrations-and-updates": "Registrations & Updates", diff --git a/src/pages/admin/dashboard/users.vue b/src/pages/admin/dashboard/users.vue index ffd63817c5..0834280a83 100644 --- a/src/pages/admin/dashboard/users.vue +++ b/src/pages/admin/dashboard/users.vue @@ -25,6 +25,27 @@ const adminStore = useAdminDashboardStore() const router = useRouter() const isLoading = ref(true) +// Onboarding funnel data +interface OnboardingFunnelData { + total_orgs: number + orgs_with_app: number + orgs_with_channel: number + orgs_with_bundle: number + app_conversion_rate: number + channel_conversion_rate: number + bundle_conversion_rate: number + trend: Array<{ + date: string + new_orgs: number + orgs_created_app: number + orgs_created_channel: number + orgs_created_bundle: number + }> +} + +const onboardingFunnelData = ref(null) +const isLoadingOnboardingFunnel = ref(false) + // Global stats trend data const globalStatsTrendData = ref { if (globalStatsTrendData.value.length === 0) @@ -284,13 +321,91 @@ const latestGlobalStats = computed(() => { return globalStatsTrendData.value[globalStatsTrendData.value.length - 1] }) +// Onboarding funnel stages for display +const onboardingFunnelStages = computed(() => { + if (!onboardingFunnelData.value) + return [] + + const data = onboardingFunnelData.value + return [ + { + label: 'Organizations Created', + value: data.total_orgs, + percentage: 100, + color: '#3b82f6', // blue + }, + { + label: 'Created an App', + value: data.orgs_with_app, + percentage: data.app_conversion_rate, + color: '#8b5cf6', // purple + }, + { + label: 'Created a Channel', + value: data.orgs_with_channel, + percentage: data.channel_conversion_rate, + color: '#f59e0b', // amber + }, + { + label: 'Uploaded a Bundle', + value: data.orgs_with_bundle, + percentage: data.bundle_conversion_rate, + color: '#10b981', // green + }, + ] +}) + +// Onboarding funnel trend for multi-line chart +const onboardingFunnelTrendSeries = computed(() => { + if (!onboardingFunnelData.value || !onboardingFunnelData.value.trend) + return [] + + const trend = onboardingFunnelData.value.trend + return [ + { + label: 'New Organizations', + data: trend.map(item => ({ + date: item.date, + value: item.new_orgs, + })), + color: '#3b82f6', // blue + }, + { + label: 'Created App (within 7 days)', + data: trend.map(item => ({ + date: item.date, + value: item.orgs_created_app, + })), + color: '#8b5cf6', // purple + }, + { + label: 'Created Channel (within 7 days)', + data: trend.map(item => ({ + date: item.date, + value: item.orgs_created_channel, + })), + color: '#f59e0b', // amber + }, + { + label: 'Uploaded Bundle (within 7 days)', + data: trend.map(item => ({ + date: item.date, + value: item.orgs_created_bundle, + })), + color: '#10b981', // green + }, + ] +}) + watch(() => adminStore.activeDateRange, () => { loadGlobalStatsTrend() + loadOnboardingFunnel() }, { deep: true }) // Watch for refresh button clicks watch(() => adminStore.refreshTrigger, () => { loadGlobalStatsTrend() + loadOnboardingFunnel() }) onMounted(async () => { @@ -301,7 +416,7 @@ onMounted(async () => { } isLoading.value = true - await Promise.all([loadGlobalStatsTrend(), loadTrialOrganizations()]) + await Promise.all([loadGlobalStatsTrend(), loadOnboardingFunnel(), loadTrialOrganizations()]) isLoading.value = false displayStore.NavTitle = t('users-and-revenue') @@ -322,6 +437,85 @@ displayStore.defaultBack = '/dashboard'
+ +
+

+ {{ t('onboarding-funnel') }} +

+

+ {{ t('onboarding-funnel-description') }} +

+
+ +
+
+ +
+
+ {{ stage.label }} + + {{ stage.value.toLocaleString() }} + + ({{ stage.percentage.toFixed(1) }}% {{ index === 1 ? 'of orgs' : 'of previous' }}) + + +
+
+
+
+
+ + +
+
+

+ {{ onboardingFunnelData?.app_conversion_rate?.toFixed(1) || 0 }}% +

+

+ Org → App +

+
+
+

+ {{ onboardingFunnelData?.channel_conversion_rate?.toFixed(1) || 0 }}% +

+

+ App → Channel +

+
+
+

+ {{ onboardingFunnelData?.bundle_conversion_rate?.toFixed(1) || 0 }}% +

+

+ Channel → Bundle +

+
+
+
+
+ {{ t('no-data-available') }} +
+
+ + + + + +
diff --git a/src/stores/adminDashboard.ts b/src/stores/adminDashboard.ts index 298e0bfd92..ee73713323 100644 --- a/src/stores/adminDashboard.ts +++ b/src/stores/adminDashboard.ts @@ -2,7 +2,7 @@ import { acceptHMRUpdate, defineStore } from 'pinia' import { computed, ref } from 'vue' import { defaultApiHost, useSupabase } from '~/services/supabase' -export type MetricCategory = 'uploads' | 'distribution' | 'failures' | 'success_rate' | 'platform_overview' | 'org_metrics' | 'mau_trend' | 'success_rate_trend' | 'apps_trend' | 'bundles_trend' | 'deployments_trend' | 'storage_trend' | 'bandwidth_trend' | 'global_stats_trend' | 'plugin_breakdown' | 'trial_organizations' +export type MetricCategory = 'uploads' | 'distribution' | 'failures' | 'success_rate' | 'platform_overview' | 'org_metrics' | 'mau_trend' | 'success_rate_trend' | 'apps_trend' | 'bundles_trend' | 'deployments_trend' | 'storage_trend' | 'bandwidth_trend' | 'global_stats_trend' | 'plugin_breakdown' | 'trial_organizations' | 'onboarding_funnel' export type DateRangeMode = '30day' | '90day' | 'quarter' | '6month' | '12month' | 'custom' interface DateRange { diff --git a/supabase/functions/_backend/private/admin_stats.ts b/supabase/functions/_backend/private/admin_stats.ts index 16ce45124e..4cfeffae27 100644 --- a/supabase/functions/_backend/private/admin_stats.ts +++ b/supabase/functions/_backend/private/admin_stats.ts @@ -4,11 +4,11 @@ import { z } from 'zod/mini' import { getAdminAppsTrend, getAdminBandwidthTrend, getAdminBundlesTrend, getAdminDistributionMetrics, getAdminFailureMetrics, getAdminMauTrend, getAdminOrgMetrics, getAdminPlatformOverview, getAdminStorageTrend, getAdminSuccessRate, getAdminSuccessRateTrend, getAdminUploadMetrics } from '../utils/cloudflare.ts' import { middlewareAuth, parseBody, simpleError, useCors } from '../utils/hono.ts' import { cloudlog } from '../utils/logging.ts' -import { getAdminDeploymentsTrend, getAdminGlobalStatsTrend, getAdminPluginBreakdown, getAdminTrialOrganizations } from '../utils/pg.ts' +import { getAdminDeploymentsTrend, getAdminGlobalStatsTrend, getAdminOnboardingFunnel, getAdminPluginBreakdown, getAdminTrialOrganizations } from '../utils/pg.ts' import { supabaseClient as useSupabaseClient } from '../utils/supabase.ts' const bodySchema = z.object({ - metric_category: z.enum(['uploads', 'distribution', 'failures', 'success_rate', 'platform_overview', 'org_metrics', 'mau_trend', 'success_rate_trend', 'apps_trend', 'bundles_trend', 'deployments_trend', 'storage_trend', 'bandwidth_trend', 'global_stats_trend', 'plugin_breakdown', 'trial_organizations']), + metric_category: z.enum(['uploads', 'distribution', 'failures', 'success_rate', 'platform_overview', 'org_metrics', 'mau_trend', 'success_rate_trend', 'apps_trend', 'bundles_trend', 'deployments_trend', 'storage_trend', 'bandwidth_trend', 'global_stats_trend', 'plugin_breakdown', 'trial_organizations', 'onboarding_funnel']), start_date: z.string().check(z.minLength(1)), end_date: z.string().check(z.minLength(1)), }) @@ -134,6 +134,10 @@ app.post('/', middlewareAuth, async (c) => { result = await getAdminTrialOrganizations(c, limit || 20, offset || 0) break + case 'onboarding_funnel': + result = await getAdminOnboardingFunnel(c, start_date, end_date) + break + default: throw simpleError('invalid_metric_category', 'Invalid metric category', { metric_category }) } diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index cca6129b95..fe164c936a 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -1123,6 +1123,178 @@ export async function getAdminTrialOrganizations( } } +// Admin Onboarding Funnel +export interface AdminOnboardingFunnel { + total_orgs: number + orgs_with_app: number + orgs_with_channel: number + orgs_with_bundle: number + // Conversion rates + app_conversion_rate: number + channel_conversion_rate: number + bundle_conversion_rate: number + // Trend data + trend: Array<{ + date: string + new_orgs: number + orgs_created_app: number + orgs_created_channel: number + orgs_created_bundle: number + }> +} + +export async function getAdminOnboardingFunnel( + c: Context, + start_date: string, + end_date: string, +): Promise { + try { + const pgClient = getPgClient(c, true) // Read-only query + const drizzleClient = getDrizzleClient(pgClient) + + // Get total funnel counts for orgs created in the date range + const funnelQuery = sql` + WITH orgs_in_range AS ( + SELECT id, created_at::date as created_date + FROM orgs + WHERE created_at >= ${start_date}::timestamp + AND created_at < ${end_date}::timestamp + ), + orgs_with_apps AS ( + SELECT DISTINCT o.id, o.created_date + FROM orgs_in_range o + INNER JOIN apps a ON a.owner_org = o.id + ), + orgs_with_channels AS ( + SELECT DISTINCT o.id, 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 + ), + orgs_with_bundles AS ( + SELECT DISTINCT o.id, 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 != 'builtin' + ) + SELECT + (SELECT COUNT(*)::int FROM orgs_in_range) as total_orgs, + (SELECT COUNT(*)::int FROM orgs_with_apps) as orgs_with_app, + (SELECT COUNT(*)::int FROM orgs_with_channels) as orgs_with_channel, + (SELECT COUNT(*)::int FROM orgs_with_bundles) as orgs_with_bundle + ` + + const funnelResult = await drizzleClient.execute(funnelQuery) + const funnelRow = funnelResult.rows[0] as any || {} + + const totalOrgs = Number(funnelRow.total_orgs) || 0 + const orgsWithApp = Number(funnelRow.orgs_with_app) || 0 + const orgsWithChannel = Number(funnelRow.orgs_with_channel) || 0 + const orgsWithBundle = Number(funnelRow.orgs_with_bundle) || 0 + + // Get daily trend data + const trendQuery = sql` + WITH date_series AS ( + SELECT generate_series( + ${start_date}::date, + ${end_date}::date - interval '1 day', + interval '1 day' + )::date as date + ), + daily_orgs AS ( + SELECT created_at::date as date, COUNT(*)::int as new_orgs + FROM orgs + WHERE created_at >= ${start_date}::timestamp + AND created_at < ${end_date}::timestamp + GROUP BY created_at::date + ), + daily_apps AS ( + SELECT o.created_at::date as date, COUNT(DISTINCT o.id)::int as orgs_created_app + FROM orgs o + 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' + GROUP BY o.created_at::date + ), + daily_channels AS ( + SELECT o.created_at::date as date, COUNT(DISTINCT o.id)::int as orgs_created_channel + FROM orgs o + INNER JOIN apps a ON a.owner_org = o.id + INNER JOIN channels c ON c.app_id = a.app_id + WHERE o.created_at >= ${start_date}::timestamp + AND o.created_at < ${end_date}::timestamp + AND c.created_at >= o.created_at + AND c.created_at < o.created_at + interval '7 days' + GROUP BY o.created_at::date + ), + daily_bundles AS ( + 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 != 'builtin' + 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' + GROUP BY o.created_at::date + ) + SELECT + ds.date, + COALESCE(do.new_orgs, 0) as new_orgs, + COALESCE(da.orgs_created_app, 0) as orgs_created_app, + COALESCE(dc.orgs_created_channel, 0) as orgs_created_channel, + COALESCE(db.orgs_created_bundle, 0) as orgs_created_bundle + FROM date_series ds + LEFT JOIN daily_orgs do ON do.date = ds.date + LEFT JOIN daily_apps da ON da.date = ds.date + LEFT JOIN daily_channels dc ON dc.date = ds.date + LEFT JOIN daily_bundles db ON db.date = ds.date + ORDER BY ds.date ASC + ` + + const trendResult = await drizzleClient.execute(trendQuery) + const trend = trendResult.rows.map((row: any) => ({ + date: row.date instanceof Date ? row.date.toISOString().split('T')[0] : row.date, + new_orgs: Number(row.new_orgs) || 0, + orgs_created_app: Number(row.orgs_created_app) || 0, + orgs_created_channel: Number(row.orgs_created_channel) || 0, + orgs_created_bundle: Number(row.orgs_created_bundle) || 0, + })) + + const result: AdminOnboardingFunnel = { + total_orgs: totalOrgs, + orgs_with_app: orgsWithApp, + orgs_with_channel: orgsWithChannel, + orgs_with_bundle: orgsWithBundle, + app_conversion_rate: totalOrgs > 0 ? (orgsWithApp / totalOrgs) * 100 : 0, + channel_conversion_rate: orgsWithApp > 0 ? (orgsWithChannel / orgsWithApp) * 100 : 0, + bundle_conversion_rate: orgsWithChannel > 0 ? (orgsWithBundle / orgsWithChannel) * 100 : 0, + trend, + } + + cloudlog({ requestId: c.get('requestId'), message: 'getAdminOnboardingFunnel result', result }) + + return result + } + catch (e: unknown) { + logPgError(c, 'getAdminOnboardingFunnel', e) + return { + total_orgs: 0, + orgs_with_app: 0, + orgs_with_channel: 0, + orgs_with_bundle: 0, + app_conversion_rate: 0, + channel_conversion_rate: 0, + bundle_conversion_rate: 0, + trend: [], + } + } +} + export async function getAdminPluginBreakdown( c: Context, start_date: string, From 4681d8daf4bbd5168a25b247c0ceb3225dc997e6 Mon Sep 17 00:00:00 2001 From: Martin DONADIEU Date: Sun, 25 Jan 2026 03:01:18 +0100 Subject: [PATCH 2/4] feat(admin): add trial organizations list table with pagination (#1495) * feat(admin): add trial organizations list table with pagination Add a new table to the admin dashboard showing organizations currently in trial period. The table displays organization name, email, days remaining, and trial end date, ordered by days remaining to show expiring trials first. Includes pagination with 20 items per page. Co-Authored-By: Claude Haiku 4.5 * fix(admin): address PR review comments - Fix SQL query to include trials expiring today (>= instead of >) - Add JSDoc comment for getAdminTrialOrganizations function - Add inline comments explaining NULL status business logic - Add TypeScript interface for API response to fix type errors - Add comment explaining why date parameters are sent but unused Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Haiku 4.5 --- supabase/functions/_backend/utils/pg.ts | 90 +++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index fe164c936a..035a2cb91a 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -1295,6 +1295,96 @@ export async function getAdminOnboardingFunnel( } } +// Admin Trial Organizations List +export interface AdminTrialOrganization { + org_id: string + org_name: string + management_email: string + trial_end_date: string + days_remaining: number + created_at: string +} + +export interface AdminTrialOrganizationsResult { + organizations: AdminTrialOrganization[] + total: number +} + +/** + * Fetches organizations currently in their trial period for the admin dashboard. + * Returns a paginated list of trial organizations ordered by days remaining (ascending), + * so organizations expiring soon appear first. + * + * Trial organizations are those where: + * - trial_at date is today or in the future (>= CURRENT_DATE) + * - status is NULL (new org, no payment attempted) or not 'succeeded' (no active subscription) + */ +export async function getAdminTrialOrganizations( + c: Context, + limit: number = 20, + offset: number = 0, +): Promise { + try { + const pgClient = getPgClient(c, true) // Read-only query + const drizzleClient = getDrizzleClient(pgClient) + + // Query to get trial organizations ordered by days remaining (ascending - expiring soon first) + // Filter logic: + // - trial_at >= CURRENT_DATE: includes trials expiring today (days_remaining = 0) + // - status IS NULL: new organizations that haven't attempted payment yet + // - status != 'succeeded': organizations without an active paid subscription + const query = sql` + SELECT + o.id AS org_id, + o.name AS org_name, + o.management_email, + si.trial_at AS trial_end_date, + GREATEST(0, (si.trial_at::date - CURRENT_DATE)) AS days_remaining, + o.created_at + FROM orgs o + INNER JOIN stripe_info si ON si.customer_id = o.customer_id + WHERE si.trial_at::date >= CURRENT_DATE + AND (si.status IS NULL OR si.status != 'succeeded') + ORDER BY days_remaining ASC, o.created_at DESC + LIMIT ${limit} + OFFSET ${offset} + ` + + // Count query for pagination + const countQuery = sql` + SELECT COUNT(*)::int AS total + FROM orgs o + INNER JOIN stripe_info si ON si.customer_id = o.customer_id + WHERE si.trial_at::date >= CURRENT_DATE + AND (si.status IS NULL OR si.status != 'succeeded') + ` + + const [result, countResult] = await Promise.all([ + drizzleClient.execute(query), + drizzleClient.execute(countQuery), + ]) + + const organizations: AdminTrialOrganization[] = result.rows.map((row: any) => ({ + org_id: row.org_id, + org_name: row.org_name, + management_email: row.management_email, + trial_end_date: row.trial_end_date, + days_remaining: Number(row.days_remaining), + created_at: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at, + })) + + const total = Number((countResult.rows[0] as any)?.total) || 0 + + cloudlog({ requestId: c.get('requestId'), message: 'getAdminTrialOrganizations result', resultCount: organizations.length, total }) + + return { organizations, total } + } + catch (e: unknown) { + logPgError(c, 'getAdminTrialOrganizations', e) + return { organizations: [], total: 0 } + } +} + export async function getAdminPluginBreakdown( c: Context, start_date: string, From 4d2ceea9eadb1575c2a3d94fd6a7ddd7813f4341 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sun, 25 Jan 2026 01:45:57 +0000 Subject: [PATCH 3/4] feat(admin): add onboarding funnel chart to users dashboard Adds a comprehensive onboarding funnel visualization to the admin dashboard that tracks user progression from organization creation through app creation, channel creation, and bundle upload. Includes both funnel visualization with conversion rates and daily trend charts. Co-Authored-By: Claude Haiku 4.5 --- supabase/functions/_backend/utils/pg.ts | 172 ++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 035a2cb91a..98fbcb4319 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -1385,6 +1385,178 @@ export async function getAdminTrialOrganizations( } } +// Admin Onboarding Funnel +export interface AdminOnboardingFunnel { + total_orgs: number + orgs_with_app: number + orgs_with_channel: number + orgs_with_bundle: number + // Conversion rates + app_conversion_rate: number + channel_conversion_rate: number + bundle_conversion_rate: number + // Trend data + trend: Array<{ + date: string + new_orgs: number + orgs_created_app: number + orgs_created_channel: number + orgs_created_bundle: number + }> +} + +export async function getAdminOnboardingFunnel( + c: Context, + start_date: string, + end_date: string, +): Promise { + try { + const pgClient = getPgClient(c, true) // Read-only query + const drizzleClient = getDrizzleClient(pgClient) + + // Get total funnel counts for orgs created in the date range + const funnelQuery = sql` + WITH orgs_in_range AS ( + SELECT id, created_at::date as created_date + FROM orgs + WHERE created_at >= ${start_date}::timestamp + AND created_at < ${end_date}::timestamp + ), + orgs_with_apps AS ( + SELECT DISTINCT o.id, o.created_date + FROM orgs_in_range o + INNER JOIN apps a ON a.owner_org = o.id + ), + orgs_with_channels AS ( + SELECT DISTINCT o.id, 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 + ), + orgs_with_bundles AS ( + SELECT DISTINCT o.id, 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 != 'builtin' + ) + SELECT + (SELECT COUNT(*)::int FROM orgs_in_range) as total_orgs, + (SELECT COUNT(*)::int FROM orgs_with_apps) as orgs_with_app, + (SELECT COUNT(*)::int FROM orgs_with_channels) as orgs_with_channel, + (SELECT COUNT(*)::int FROM orgs_with_bundles) as orgs_with_bundle + ` + + const funnelResult = await drizzleClient.execute(funnelQuery) + const funnelRow = funnelResult.rows[0] as any || {} + + const totalOrgs = Number(funnelRow.total_orgs) || 0 + const orgsWithApp = Number(funnelRow.orgs_with_app) || 0 + const orgsWithChannel = Number(funnelRow.orgs_with_channel) || 0 + const orgsWithBundle = Number(funnelRow.orgs_with_bundle) || 0 + + // Get daily trend data + const trendQuery = sql` + WITH date_series AS ( + SELECT generate_series( + ${start_date}::date, + ${end_date}::date - interval '1 day', + interval '1 day' + )::date as date + ), + daily_orgs AS ( + SELECT created_at::date as date, COUNT(*)::int as new_orgs + FROM orgs + WHERE created_at >= ${start_date}::timestamp + AND created_at < ${end_date}::timestamp + GROUP BY created_at::date + ), + daily_apps AS ( + SELECT o.created_at::date as date, COUNT(DISTINCT o.id)::int as orgs_created_app + FROM orgs o + 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' + GROUP BY o.created_at::date + ), + daily_channels AS ( + SELECT o.created_at::date as date, COUNT(DISTINCT o.id)::int as orgs_created_channel + FROM orgs o + INNER JOIN apps a ON a.owner_org = o.id + INNER JOIN channels c ON c.app_id = a.app_id + WHERE o.created_at >= ${start_date}::timestamp + AND o.created_at < ${end_date}::timestamp + AND c.created_at >= o.created_at + AND c.created_at < o.created_at + interval '7 days' + GROUP BY o.created_at::date + ), + daily_bundles AS ( + 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 != 'builtin' + 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' + GROUP BY o.created_at::date + ) + SELECT + ds.date, + COALESCE(do.new_orgs, 0) as new_orgs, + COALESCE(da.orgs_created_app, 0) as orgs_created_app, + COALESCE(dc.orgs_created_channel, 0) as orgs_created_channel, + COALESCE(db.orgs_created_bundle, 0) as orgs_created_bundle + FROM date_series ds + LEFT JOIN daily_orgs do ON do.date = ds.date + LEFT JOIN daily_apps da ON da.date = ds.date + LEFT JOIN daily_channels dc ON dc.date = ds.date + LEFT JOIN daily_bundles db ON db.date = ds.date + ORDER BY ds.date ASC + ` + + const trendResult = await drizzleClient.execute(trendQuery) + const trend = trendResult.rows.map((row: any) => ({ + date: row.date instanceof Date ? row.date.toISOString().split('T')[0] : row.date, + new_orgs: Number(row.new_orgs) || 0, + orgs_created_app: Number(row.orgs_created_app) || 0, + orgs_created_channel: Number(row.orgs_created_channel) || 0, + orgs_created_bundle: Number(row.orgs_created_bundle) || 0, + })) + + const result: AdminOnboardingFunnel = { + total_orgs: totalOrgs, + orgs_with_app: orgsWithApp, + orgs_with_channel: orgsWithChannel, + orgs_with_bundle: orgsWithBundle, + app_conversion_rate: totalOrgs > 0 ? (orgsWithApp / totalOrgs) * 100 : 0, + channel_conversion_rate: orgsWithApp > 0 ? (orgsWithChannel / orgsWithApp) * 100 : 0, + bundle_conversion_rate: orgsWithChannel > 0 ? (orgsWithBundle / orgsWithChannel) * 100 : 0, + trend, + } + + cloudlog({ requestId: c.get('requestId'), message: 'getAdminOnboardingFunnel result', result }) + + return result + } + catch (e: unknown) { + logPgError(c, 'getAdminOnboardingFunnel', e) + return { + total_orgs: 0, + orgs_with_app: 0, + orgs_with_channel: 0, + orgs_with_bundle: 0, + app_conversion_rate: 0, + channel_conversion_rate: 0, + bundle_conversion_rate: 0, + trend: [], + } + } +} + export async function getAdminPluginBreakdown( c: Context, start_date: string, From 2425db5d1cc50c02d669caec19584845ec39e3ec Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sun, 25 Jan 2026 04:14:49 +0000 Subject: [PATCH 4/4] fix(admin): remove duplicate admin stats helpers --- supabase/functions/_backend/utils/pg.ts | 262 ------------------------ 1 file changed, 262 deletions(-) diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 98fbcb4319..fe164c936a 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -1295,268 +1295,6 @@ export async function getAdminOnboardingFunnel( } } -// Admin Trial Organizations List -export interface AdminTrialOrganization { - org_id: string - org_name: string - management_email: string - trial_end_date: string - days_remaining: number - created_at: string -} - -export interface AdminTrialOrganizationsResult { - organizations: AdminTrialOrganization[] - total: number -} - -/** - * Fetches organizations currently in their trial period for the admin dashboard. - * Returns a paginated list of trial organizations ordered by days remaining (ascending), - * so organizations expiring soon appear first. - * - * Trial organizations are those where: - * - trial_at date is today or in the future (>= CURRENT_DATE) - * - status is NULL (new org, no payment attempted) or not 'succeeded' (no active subscription) - */ -export async function getAdminTrialOrganizations( - c: Context, - limit: number = 20, - offset: number = 0, -): Promise { - try { - const pgClient = getPgClient(c, true) // Read-only query - const drizzleClient = getDrizzleClient(pgClient) - - // Query to get trial organizations ordered by days remaining (ascending - expiring soon first) - // Filter logic: - // - trial_at >= CURRENT_DATE: includes trials expiring today (days_remaining = 0) - // - status IS NULL: new organizations that haven't attempted payment yet - // - status != 'succeeded': organizations without an active paid subscription - const query = sql` - SELECT - o.id AS org_id, - o.name AS org_name, - o.management_email, - si.trial_at AS trial_end_date, - GREATEST(0, (si.trial_at::date - CURRENT_DATE)) AS days_remaining, - o.created_at - FROM orgs o - INNER JOIN stripe_info si ON si.customer_id = o.customer_id - WHERE si.trial_at::date >= CURRENT_DATE - AND (si.status IS NULL OR si.status != 'succeeded') - ORDER BY days_remaining ASC, o.created_at DESC - LIMIT ${limit} - OFFSET ${offset} - ` - - // Count query for pagination - const countQuery = sql` - SELECT COUNT(*)::int AS total - FROM orgs o - INNER JOIN stripe_info si ON si.customer_id = o.customer_id - WHERE si.trial_at::date >= CURRENT_DATE - AND (si.status IS NULL OR si.status != 'succeeded') - ` - - const [result, countResult] = await Promise.all([ - drizzleClient.execute(query), - drizzleClient.execute(countQuery), - ]) - - const organizations: AdminTrialOrganization[] = result.rows.map((row: any) => ({ - org_id: row.org_id, - org_name: row.org_name, - management_email: row.management_email, - trial_end_date: row.trial_end_date, - days_remaining: Number(row.days_remaining), - created_at: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at, - })) - - const total = Number((countResult.rows[0] as any)?.total) || 0 - - cloudlog({ requestId: c.get('requestId'), message: 'getAdminTrialOrganizations result', resultCount: organizations.length, total }) - - return { organizations, total } - } - catch (e: unknown) { - logPgError(c, 'getAdminTrialOrganizations', e) - return { organizations: [], total: 0 } - } -} - -// Admin Onboarding Funnel -export interface AdminOnboardingFunnel { - total_orgs: number - orgs_with_app: number - orgs_with_channel: number - orgs_with_bundle: number - // Conversion rates - app_conversion_rate: number - channel_conversion_rate: number - bundle_conversion_rate: number - // Trend data - trend: Array<{ - date: string - new_orgs: number - orgs_created_app: number - orgs_created_channel: number - orgs_created_bundle: number - }> -} - -export async function getAdminOnboardingFunnel( - c: Context, - start_date: string, - end_date: string, -): Promise { - try { - const pgClient = getPgClient(c, true) // Read-only query - const drizzleClient = getDrizzleClient(pgClient) - - // Get total funnel counts for orgs created in the date range - const funnelQuery = sql` - WITH orgs_in_range AS ( - SELECT id, created_at::date as created_date - FROM orgs - WHERE created_at >= ${start_date}::timestamp - AND created_at < ${end_date}::timestamp - ), - orgs_with_apps AS ( - SELECT DISTINCT o.id, o.created_date - FROM orgs_in_range o - INNER JOIN apps a ON a.owner_org = o.id - ), - orgs_with_channels AS ( - SELECT DISTINCT o.id, 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 - ), - orgs_with_bundles AS ( - SELECT DISTINCT o.id, 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 != 'builtin' - ) - SELECT - (SELECT COUNT(*)::int FROM orgs_in_range) as total_orgs, - (SELECT COUNT(*)::int FROM orgs_with_apps) as orgs_with_app, - (SELECT COUNT(*)::int FROM orgs_with_channels) as orgs_with_channel, - (SELECT COUNT(*)::int FROM orgs_with_bundles) as orgs_with_bundle - ` - - const funnelResult = await drizzleClient.execute(funnelQuery) - const funnelRow = funnelResult.rows[0] as any || {} - - const totalOrgs = Number(funnelRow.total_orgs) || 0 - const orgsWithApp = Number(funnelRow.orgs_with_app) || 0 - const orgsWithChannel = Number(funnelRow.orgs_with_channel) || 0 - const orgsWithBundle = Number(funnelRow.orgs_with_bundle) || 0 - - // Get daily trend data - const trendQuery = sql` - WITH date_series AS ( - SELECT generate_series( - ${start_date}::date, - ${end_date}::date - interval '1 day', - interval '1 day' - )::date as date - ), - daily_orgs AS ( - SELECT created_at::date as date, COUNT(*)::int as new_orgs - FROM orgs - WHERE created_at >= ${start_date}::timestamp - AND created_at < ${end_date}::timestamp - GROUP BY created_at::date - ), - daily_apps AS ( - SELECT o.created_at::date as date, COUNT(DISTINCT o.id)::int as orgs_created_app - FROM orgs o - 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' - GROUP BY o.created_at::date - ), - daily_channels AS ( - SELECT o.created_at::date as date, COUNT(DISTINCT o.id)::int as orgs_created_channel - FROM orgs o - INNER JOIN apps a ON a.owner_org = o.id - INNER JOIN channels c ON c.app_id = a.app_id - WHERE o.created_at >= ${start_date}::timestamp - AND o.created_at < ${end_date}::timestamp - AND c.created_at >= o.created_at - AND c.created_at < o.created_at + interval '7 days' - GROUP BY o.created_at::date - ), - daily_bundles AS ( - 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 != 'builtin' - 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' - GROUP BY o.created_at::date - ) - SELECT - ds.date, - COALESCE(do.new_orgs, 0) as new_orgs, - COALESCE(da.orgs_created_app, 0) as orgs_created_app, - COALESCE(dc.orgs_created_channel, 0) as orgs_created_channel, - COALESCE(db.orgs_created_bundle, 0) as orgs_created_bundle - FROM date_series ds - LEFT JOIN daily_orgs do ON do.date = ds.date - LEFT JOIN daily_apps da ON da.date = ds.date - LEFT JOIN daily_channels dc ON dc.date = ds.date - LEFT JOIN daily_bundles db ON db.date = ds.date - ORDER BY ds.date ASC - ` - - const trendResult = await drizzleClient.execute(trendQuery) - const trend = trendResult.rows.map((row: any) => ({ - date: row.date instanceof Date ? row.date.toISOString().split('T')[0] : row.date, - new_orgs: Number(row.new_orgs) || 0, - orgs_created_app: Number(row.orgs_created_app) || 0, - orgs_created_channel: Number(row.orgs_created_channel) || 0, - orgs_created_bundle: Number(row.orgs_created_bundle) || 0, - })) - - const result: AdminOnboardingFunnel = { - total_orgs: totalOrgs, - orgs_with_app: orgsWithApp, - orgs_with_channel: orgsWithChannel, - orgs_with_bundle: orgsWithBundle, - app_conversion_rate: totalOrgs > 0 ? (orgsWithApp / totalOrgs) * 100 : 0, - channel_conversion_rate: orgsWithApp > 0 ? (orgsWithChannel / orgsWithApp) * 100 : 0, - bundle_conversion_rate: orgsWithChannel > 0 ? (orgsWithBundle / orgsWithChannel) * 100 : 0, - trend, - } - - cloudlog({ requestId: c.get('requestId'), message: 'getAdminOnboardingFunnel result', result }) - - return result - } - catch (e: unknown) { - logPgError(c, 'getAdminOnboardingFunnel', e) - return { - total_orgs: 0, - orgs_with_app: 0, - orgs_with_channel: 0, - orgs_with_bundle: 0, - app_conversion_rate: 0, - channel_conversion_rate: 0, - bundle_conversion_rate: 0, - trend: [], - } - } -} - export async function getAdminPluginBreakdown( c: Context, start_date: string,