diff --git a/.github/pr-screenshots/admin-apps-with-store-url.webp b/.github/pr-screenshots/admin-apps-with-store-url.webp new file mode 100644 index 0000000000..fd1f7678bb Binary files /dev/null and b/.github/pr-screenshots/admin-apps-with-store-url.webp differ diff --git a/messages/en.context.json b/messages/en.context.json index f767bba23f..d3e82d3118 100644 --- a/messages/en.context.json +++ b/messages/en.context.json @@ -201,6 +201,7 @@ "admin-app-build-onboarding-total-apps-created": "Used in platform admin dashboard. Role: UI label about \"admin app build onboarding total apps created\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "admin-apps-created-by-day": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "admin-apps-created-by-day-series": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "admin-apps-with-store-url-series": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "admin-apps-with-preview": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "admin-apps-with-preview-series": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "admin-credits": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", diff --git a/messages/en.json b/messages/en.json index f85ed0df9e..c59361b3cb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -196,6 +196,7 @@ "admin-app-build-onboarding-total-apps-created": "Apps created", "admin-apps-created-by-day": "Apps Created by Day", "admin-apps-created-by-day-series": "Apps created", + "admin-apps-with-store-url-series": "Apps with store link", "admin-apps-with-preview": "Apps with Preview QR Enabled", "admin-apps-with-preview-series": "Apps with preview QR", "admin-versions-uploaded-by-day": "Versions Uploaded by Day", diff --git a/src/pages/admin/dashboard/users.vue b/src/pages/admin/dashboard/users.vue index 6b9785c73b..18dbfc95c5 100644 --- a/src/pages/admin/dashboard/users.vue +++ b/src/pages/admin/dashboard/users.vue @@ -159,6 +159,7 @@ const globalStatsTrendData = ref { })), color: '#2563eb', }, + { + label: t('admin-apps-with-store-url-series'), + data: globalStatsTrendData.value.map(item => ({ + date: item.date, + value: item.apps_with_store_url ?? 0, + })), + color: '#f59e0b', + }, ] }) diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index c2538eb15c..e667489434 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -1613,6 +1613,7 @@ export type Database = { apps_with_cli_onboarding_builds_24h: number apps_with_manual_builds_24h: number apps_with_preview: number + apps_with_store_url: number average_ltv: number build_avg_seconds_day_android: number build_avg_seconds_day_ios: number @@ -1724,6 +1725,7 @@ export type Database = { apps_with_cli_onboarding_builds_24h?: number apps_with_manual_builds_24h?: number apps_with_preview?: number + apps_with_store_url?: number average_ltv?: number build_avg_seconds_day_android?: number build_avg_seconds_day_ios?: number @@ -1835,6 +1837,7 @@ export type Database = { apps_with_cli_onboarding_builds_24h?: number apps_with_manual_builds_24h?: number apps_with_preview?: number + apps_with_store_url?: number average_ltv?: number build_avg_seconds_day_android?: number build_avg_seconds_day_ios?: number diff --git a/supabase/functions/_backend/plugin_runtime/utils/pg.ts b/supabase/functions/_backend/plugin_runtime/utils/pg.ts index 9416b49488..acb8d0a7a3 100644 --- a/supabase/functions/_backend/plugin_runtime/utils/pg.ts +++ b/supabase/functions/_backend/plugin_runtime/utils/pg.ts @@ -1911,6 +1911,7 @@ export interface AdminGlobalStatsTrend { app_build_onboarding_finalized: boolean apps_active: number apps_with_preview: number + apps_with_store_url: number users: number users_active: number paying: number @@ -2039,6 +2040,7 @@ export async function getAdminGlobalStatsTrend( (onboarding_next.date_id IS NOT NULL)::boolean AS app_build_onboarding_finalized, gs.apps_active::int AS apps_active, COALESCE(NULLIF(to_jsonb(gs) ->> 'apps_with_preview', '')::int, 0)::int AS apps_with_preview, + COALESCE(NULLIF(to_jsonb(gs) ->> 'apps_with_store_url', '')::int, 0)::int AS apps_with_store_url, gs.users::int AS users, gs.users_active::int AS users_active, gs.paying::int AS paying, @@ -2196,6 +2198,7 @@ export async function getAdminGlobalStatsTrend( app_build_onboarding_finalized: row.app_build_onboarding_finalized === true || row.app_build_onboarding_finalized === 'true', apps_active: Number(row.apps_active) || 0, apps_with_preview: Number(row.apps_with_preview) || 0, + apps_with_store_url: Number(row.apps_with_store_url) || 0, users: Number(row.users) || 0, users_active: Number(row.users_active) || 0, paying: Number(row.paying) || 0, diff --git a/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts b/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts index 57169ccf3a..28c7b03ade 100644 --- a/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts +++ b/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts @@ -1545,6 +1545,7 @@ export type Database = { apps_with_cli_onboarding_builds_24h: number apps_with_manual_builds_24h: number apps_with_preview: number + apps_with_store_url: number average_ltv: number build_avg_seconds_day_android: number build_avg_seconds_day_ios: number @@ -1646,6 +1647,7 @@ export type Database = { apps_with_cli_onboarding_builds_24h?: number apps_with_manual_builds_24h?: number apps_with_preview?: number + apps_with_store_url?: number average_ltv?: number build_avg_seconds_day_android?: number build_avg_seconds_day_ios?: number @@ -1747,6 +1749,7 @@ export type Database = { apps_with_cli_onboarding_builds_24h?: number apps_with_manual_builds_24h?: number apps_with_preview?: number + apps_with_store_url?: number average_ltv?: number build_avg_seconds_day_android?: number build_avg_seconds_day_ios?: number diff --git a/supabase/functions/_backend/triggers/logsnag_insights.ts b/supabase/functions/_backend/triggers/logsnag_insights.ts index 827f5c6c73..764b032db5 100644 --- a/supabase/functions/_backend/triggers/logsnag_insights.ts +++ b/supabase/functions/_backend/triggers/logsnag_insights.ts @@ -452,11 +452,13 @@ type GlobalStatsSnapshotPatch = GlobalStatsUpdate & { orgs?: number // Present in repo migrations before prod deploy; keep writable while generated types lag. apps_with_preview?: number + apps_with_store_url?: number plan_credits?: number } type GlobalStatsSnapshotRow = GlobalStatsRow & { orgs?: number | null apps_with_preview?: number | null + apps_with_store_url?: number | null plan_credits?: number | null } @@ -940,6 +942,10 @@ function isMissingAppsWithPreviewColumnError(error: unknown): boolean { return isMissingSchemaColumnError(error, ['apps_with_preview']) } +function isMissingAppsWithStoreUrlColumnError(error: unknown): boolean { + return isMissingSchemaColumnError(error, ['apps_with_store_url']) +} + async function calculateRevenue(c: Context, referenceDate?: Date): Promise { const pgClient = getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) @@ -1577,6 +1583,32 @@ async function countAppsWithPreview(c: Context, snapshotEnd: Date): Promise { + const pgClient = getPgClient(c, false) + const drizzleClient = getDrizzleClient(pgClient) + + try { + const result = await drizzleClient.execute<{ count: number }>(sql` + SELECT COUNT(*)::int AS count + FROM public.apps AS apps + WHERE apps.created_at < ${snapshotEnd} + AND ( + apps.ios_store_url ~ '[^[:space:]]' + OR apps.android_store_url ~ '[^[:space:]]' + ) + `) + + return Number(result.rows[0]?.count) || 0 + } + catch (error) { + cloudlogErr({ requestId: c.get('requestId'), message: 'countAppsWithStoreUrl error', error }) + return 0 + } + finally { + closeClient(c, pgClient) + } +} + async function getTrialExtensionStats(c: Context, window: CurrentDayWindow): Promise { const pgClient = getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) @@ -1655,10 +1687,11 @@ async function ensureGlobalStatsSnapshotRows(c: Context, dateIds: readonly strin async function updateGlobalStatsSnapshot(c: Context, dateId: string, patch: GlobalStatsSnapshotPatch): Promise { await ensureGlobalStatsSnapshotRow(c, dateId) - const { orgs, apps_with_preview, ...globalStatsPatch } = patch + const { orgs, apps_with_preview, apps_with_store_url, ...globalStatsPatch } = patch const updatePayload = { ...globalStatsPatch, ...(apps_with_preview === undefined ? {} : { apps_with_preview }), + ...(apps_with_store_url === undefined ? {} : { apps_with_store_url }), } as GlobalStatsUpdate const { error } = await supabaseAdmin(c) .from('global_stats') @@ -1666,16 +1699,24 @@ async function updateGlobalStatsSnapshot(c: Context, dateId: string, patch: Glob .eq('date_id', dateId) if (error) { - if (apps_with_preview !== undefined && isMissingAppsWithPreviewColumnError(error)) { + const missingPreview = apps_with_preview !== undefined && isMissingAppsWithPreviewColumnError(error) + const missingStoreUrl = apps_with_store_url !== undefined && isMissingAppsWithStoreUrlColumnError(error) + if (missingPreview || missingStoreUrl) { cloudlog({ requestId: c.get('requestId'), - message: 'global_stats.apps_with_preview missing; retrying snapshot update without it', + message: 'global_stats optional app metric column missing; retrying snapshot update without it', dateId, + missingPreview, + missingStoreUrl, error, }) const { error: legacyError } = await supabaseAdmin(c) .from('global_stats') - .update(globalStatsPatch as GlobalStatsUpdate) + .update({ + ...globalStatsPatch, + ...(missingPreview || apps_with_preview === undefined ? {} : { apps_with_preview }), + ...(missingStoreUrl || apps_with_store_url === undefined ? {} : { apps_with_store_url }), + } as GlobalStatsUpdate) .eq('date_id', dateId) if (legacyError) throw legacyError @@ -2737,6 +2778,7 @@ async function runCoreGlobalStatsShard(c: Context, window: DailyWindow): Promise const [ apps, apps_with_preview, + apps_with_store_url, updates, updates_external, users, @@ -2749,6 +2791,7 @@ async function runCoreGlobalStatsShard(c: Context, window: DailyWindow): Promise ] = await Promise.all([ countAllApps(c, window.prevDayEnd), countAppsWithPreview(c, window.prevDayEnd), + countAppsWithStoreUrl(c, window.prevDayEnd), countAllUpdates(c, window.prevDayEnd), countAllUpdatesExternal(c, window.prevDayEnd), countRegisteredUsersForSnapshot(c, window.prevDayEnd), @@ -2790,6 +2833,7 @@ async function runCoreGlobalStatsShard(c: Context, window: DailyWindow): Promise apps, apps_active: actives.apps, apps_with_preview, + apps_with_store_url, above_plan_with_credits, above_plan_without_credits, need_upgrade, @@ -2818,7 +2862,7 @@ async function runCoreGlobalStatsShard(c: Context, window: DailyWindow): Promise users_active: actives.users, }) - cloudlog({ requestId: c.get('requestId'), message: 'Updated global stats core shard', dateId: window.prevDayDateId, finalizedAppBuildOnboardingDateId: finalizedAppBuildOnboardingWindow.prevDayDateId, apps, apps_with_preview, updates, users, orgs }) + cloudlog({ requestId: c.get('requestId'), message: 'Updated global stats core shard', dateId: window.prevDayDateId, finalizedAppBuildOnboardingDateId: finalizedAppBuildOnboardingWindow.prevDayDateId, apps, apps_with_preview, apps_with_store_url, updates, users, orgs }) } async function getRegistersToday(c: Context, createdAfterIso: string, createdBeforeIso: string): Promise { diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 3b99d79c84..177deae895 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -1540,6 +1540,7 @@ export interface AdminGlobalStatsTrend { app_build_onboarding_finalized: boolean apps_active: number apps_with_preview: number + apps_with_store_url: number users: number users_active: number paying: number @@ -1679,6 +1680,7 @@ export async function getAdminGlobalStatsTrend( (onboarding_next.date_id IS NOT NULL)::boolean AS app_build_onboarding_finalized, gs.apps_active::int AS apps_active, COALESCE(NULLIF(to_jsonb(gs) ->> 'apps_with_preview', '')::int, 0)::int AS apps_with_preview, + COALESCE(NULLIF(to_jsonb(gs) ->> 'apps_with_store_url', '')::int, 0)::int AS apps_with_store_url, gs.users::int AS users, gs.users_active::int AS users_active, gs.paying::int AS paying, @@ -1847,6 +1849,7 @@ export async function getAdminGlobalStatsTrend( app_build_onboarding_finalized: row.app_build_onboarding_finalized === true || row.app_build_onboarding_finalized === 'true', apps_active: Number(row.apps_active) || 0, apps_with_preview: Number(row.apps_with_preview) || 0, + apps_with_store_url: Number(row.apps_with_store_url) || 0, users: Number(row.users) || 0, users_active: Number(row.users_active) || 0, paying: Number(row.paying) || 0, diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index c2538eb15c..e667489434 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -1613,6 +1613,7 @@ export type Database = { apps_with_cli_onboarding_builds_24h: number apps_with_manual_builds_24h: number apps_with_preview: number + apps_with_store_url: number average_ltv: number build_avg_seconds_day_android: number build_avg_seconds_day_ios: number @@ -1724,6 +1725,7 @@ export type Database = { apps_with_cli_onboarding_builds_24h?: number apps_with_manual_builds_24h?: number apps_with_preview?: number + apps_with_store_url?: number average_ltv?: number build_avg_seconds_day_android?: number build_avg_seconds_day_ios?: number @@ -1835,6 +1837,7 @@ export type Database = { apps_with_cli_onboarding_builds_24h?: number apps_with_manual_builds_24h?: number apps_with_preview?: number + apps_with_store_url?: number average_ltv?: number build_avg_seconds_day_android?: number build_avg_seconds_day_ios?: number diff --git a/supabase/migrations/20260818121505_apps_with_store_url_global_stats.sql b/supabase/migrations/20260818121505_apps_with_store_url_global_stats.sql new file mode 100644 index 0000000000..7073ba458e --- /dev/null +++ b/supabase/migrations/20260818121505_apps_with_store_url_global_stats.sql @@ -0,0 +1,5 @@ +ALTER TABLE public.global_stats + ADD COLUMN IF NOT EXISTS apps_with_store_url bigint NOT NULL DEFAULT 0; + +COMMENT ON COLUMN public.global_stats.apps_with_store_url + IS 'Number of apps with at least one App Store or Google Play link at snapshot day end.'; diff --git a/tests/admin-stats.test.ts b/tests/admin-stats.test.ts index 28ee5c91d2..a24b207ebe 100644 --- a/tests/admin-stats.test.ts +++ b/tests/admin-stats.test.ts @@ -166,6 +166,7 @@ beforeAll(async () => { date_id: GLOBAL_STATS_TREND_DATES[0], apps: 10, apps_created: 2, + apps_with_store_url: 4, versions_created: 5, apps_with_cli_onboarding_builds_24h: 1, apps_with_manual_builds_24h: 0, @@ -211,6 +212,7 @@ beforeAll(async () => { date_id: GLOBAL_STATS_TREND_DATES[1], apps: 11, apps_created: 3, + apps_with_store_url: 6, versions_created: 8, apps_with_cli_onboarding_builds_24h: 2, apps_with_manual_builds_24h: 1, @@ -256,6 +258,7 @@ beforeAll(async () => { date_id: GLOBAL_STATS_TREND_DATES[2], apps: 12, apps_created: 0, + apps_with_store_url: 0, versions_created: 0, apps_with_cli_onboarding_builds_24h: 0, apps_with_manual_builds_24h: 0, @@ -1005,6 +1008,7 @@ describe('/private/admin_stats', () => { date: string apps: number apps_created: number + apps_with_store_url: number versions_created: number apps_with_cli_onboarding_builds_24h: number apps_with_manual_builds_24h: number @@ -1034,6 +1038,7 @@ describe('/private/admin_stats', () => { expect(latest).toBeTruthy() expect(latest?.apps).toBe(11) expect(latest?.apps_created).toBe(3) + expect(latest?.apps_with_store_url).toBe(6) expect(latest?.versions_created).toBe(8) expect(latest?.apps_with_cli_onboarding_builds_24h).toBe(2) expect(latest?.app_build_onboarding_finalized).toBe(true) diff --git a/tests/logsnag-insights-revenue.unit.test.ts b/tests/logsnag-insights-revenue.unit.test.ts index 6e01800ea1..aec7e141cd 100644 --- a/tests/logsnag-insights-revenue.unit.test.ts +++ b/tests/logsnag-insights-revenue.unit.test.ts @@ -804,6 +804,22 @@ describe('logsnag revenue metric helpers', () => { expect(source).toContain('apps_with_preview?: number') expect(source).toContain('isMissingAppsWithPreviewColumnError') }) + + it.concurrent('snapshots apps with at least one store URL in the core global stats shard', () => { + const source = readFileSync(new URL('../supabase/functions/_backend/triggers/logsnag_insights.ts', import.meta.url), 'utf8') + const countFn = source.match(/async function countAppsWithStoreUrl[\s\S]*?async function getTrialExtensionStats/)?.[0] ?? '' + const coreShard = source.match(/async function runCoreGlobalStatsShard[\s\S]*?async function getRegistersToday/)?.[0] ?? '' + + expect(countFn).toContain('apps.ios_store_url') + expect(countFn).toContain('apps.android_store_url') + expect(countFn).toContain('[^[:space:]]') + expect(countFn).toContain('apps.created_at <') + expect(countFn).toContain('snapshotEnd') + expect(coreShard).toContain('countAppsWithStoreUrl(c, window.prevDayEnd)') + expect(coreShard).toContain('apps_with_store_url,') + expect(source).toContain('apps_with_store_url?: number') + expect(source).toContain('isMissingAppsWithStoreUrlColumnError') + }) it.concurrent('normalizes logsnag insights retry payload counts', () => { expect(logsnagInsightsTestUtils.normalizeLogsnagInsightsRetryCount('2')).toBe(2) expect(logsnagInsightsTestUtils.normalizeLogsnagInsightsRetryCount(2.8)).toBe(2)