diff --git a/cli/src/bundle/compatibility.ts b/cli/src/bundle/compatibility.ts index fb6dcedba6..347d92d8f2 100644 --- a/cli/src/bundle/compatibility.ts +++ b/cli/src/bundle/compatibility.ts @@ -129,6 +129,45 @@ export async function checkCompatibilityInternal( } } + // Surface incompatible results from the explicit `capgo bundle compatibility` + // command to Bento via the backend. The silent internal callers (sdk.ts, + // releaseType.ts) are intentionally excluded. The command uploads nothing, so + // only the channel's current (old) version is reported — version_new is empty. + if (hasIncompatible && !silent) { + // Best-effort telemetry: the org/version lookups are awaited, so a network + // or auth failure must be swallowed here — it must never break the command. + try { + const [channelResult, appResult] = await Promise.all([ + supabase.from('channels').select('version ( id, name )').eq('name', channel).eq('app_id', resolvedAppId).maybeSingle(), + supabase.from('apps').select('owner_org').eq('app_id', resolvedAppId).maybeSingle(), + ]) + const oldVersion = (channelResult.data?.version ?? undefined) as unknown as { id?: number | string, name?: string } | undefined + // The command re-queries the channel separately, so version_old can come + // back unresolved (race with a channel repoint, or a transient failure). + // version_old is required for this signal — skip the event entirely rather + // than emit one missing the version it is about. + if (oldVersion?.id != null) { + void trackEvent({ + channel: 'bundle', + event: 'Bundle Incompatible', + icon: '🚫', + apikey: enrichedOptions.apikey, + appId: resolvedAppId, + orgId: appResult.data?.owner_org ?? undefined, + tags: { + source: 'command', + channel, + version_old_id: String(oldVersion.id), + ...(oldVersion.name ? { version_old_name: oldVersion.name } : {}), + }, + }) + } + } + catch { + // telemetry must never break a command + } + } + return { finalCompatibility: compatibility.finalCompatibility, hasIncompatible, diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts index 675b6e14e1..ecd4f01194 100644 --- a/cli/src/bundle/upload.ts +++ b/cli/src/bundle/upload.ts @@ -140,7 +140,7 @@ async function verifyCompatibility(supabase: SupabaseType, pm: pmType, options: const { data: channelData, error: channelError } = await supabase .from('channels') - .select('disable_auto_update, version ( min_update_version, native_packages )') + .select('disable_auto_update, version ( id, name, min_update_version, native_packages )') .eq('name', channel) .eq('app_id', appid) .maybeSingle() @@ -148,6 +148,11 @@ async function verifyCompatibility(supabase: SupabaseType, pm: pmType, options: if (channelError) uploadFail(`Cannot load channel ${channel} for compatibility checks ${formatError(channelError)}`) + // The version currently live on the channel — what the new bundle is compared + // against. Captured here (before the channel is repointed at the new bundle) + // so the incompatible-bundle Bento signal can report the prior version. + const oldVersion = (channelData?.version ?? undefined) as unknown as { id?: number | string, name?: string } | undefined + const updateMetadataRequired = !!channelData && channelData.disable_auto_update === 'version_number' let localDependencies: Awaited> | undefined @@ -251,7 +256,15 @@ async function verifyCompatibility(supabase: SupabaseType, pm: pmType, options: })) : undefined - return { nativePackages, minUpdateVersion } + return { + nativePackages, + minUpdateVersion, + compatibility: { + result: compatibilitySummary.result, + versionOldId: oldVersion?.id != null ? String(oldVersion.id) : undefined, + versionOldName: oldVersion?.name, + }, + } } async function checkVersionExists(supabase: SupabaseType, appid: string, bundle: string, versionExistsOk = false, interactive = false): Promise { @@ -882,7 +895,7 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl if (options.verbose) log.info(`[Verbose] Checking compatibility with channel ${channel}...`) - const { nativePackages, minUpdateVersion } = await verifyCompatibility(supabase, pm, options, channel, appid, bundle, orgId) + const { nativePackages, minUpdateVersion, compatibility } = await verifyCompatibility(supabase, pm, options, channel, appid, bundle, orgId) if (options.verbose) { log.info(`[Verbose] Compatibility check completed:`) log.info(` - Native packages: ${nativePackages ? nativePackages.length : 0}`) @@ -1310,6 +1323,28 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl notifyConsole: true, }).catch(() => {}) + // Surface incompatible uploads to Bento via the backend (it resolves + // version_new_id + org/app names and gates delivery via the + // bundle_incompatible preference). Fires only once the version exists and the + // channel has been set, and only when the bundle was actually incompatible. + if (compatibility?.result === 'incompatible') { + void trackEvent({ + channel: 'bundle', + event: 'Bundle Incompatible', + icon: '🚫', + apikey, + appId: appid, + orgId, + tags: { + source: 'upload', + channel, + version_new_name: bundle, + ...(compatibility.versionOldId ? { version_old_id: compatibility.versionOldId } : {}), + ...(compatibility.versionOldName ? { version_old_name: compatibility.versionOldName } : {}), + }, + }) + } + const result: UploadBundleResult = { success: true, bundle, diff --git a/messages/en.json b/messages/en.json index aca9319634..5ee0459575 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1340,6 +1340,8 @@ "notifications-bundle-created-desc": "Receive emails when a new bundle is uploaded to your app", "notifications-bundle-deployed": "Bundle deployments", "notifications-bundle-deployed-desc": "Receive emails when a bundle is deployed to a production channel", + "notifications-bundle-incompatible": "Incompatible bundles", + "notifications-bundle-incompatible-desc": "Receive emails when an uploaded bundle is incompatible with the channel's native packages and may need a native app store update", "notifications-channel-self-rejected": "Channel self-assignment rejected", "notifications-channel-self-rejected-desc": "Receive emails when devices are rejected from self-assigning to channels", "notifications-cli-realtime-feed": "CLI activity notifications", diff --git a/src/pages/settings/account/Notifications.vue b/src/pages/settings/account/Notifications.vue index d9ee004780..8ddd521aea 100644 --- a/src/pages/settings/account/Notifications.vue +++ b/src/pages/settings/account/Notifications.vue @@ -28,6 +28,7 @@ interface EmailPreferences { bundle_deployed?: boolean device_error?: boolean channel_self_rejected?: boolean + bundle_incompatible?: boolean cli_realtime_feed?: boolean } @@ -221,6 +222,12 @@ async function toggleEmailPref(key: EmailPreferenceKey) { @change="toggleEmailPref('channel_self_rejected')" /> + + + diff --git a/supabase/functions/_backend/private/events.ts b/supabase/functions/_backend/private/events.ts index a2800723a0..6d20130019 100644 --- a/supabase/functions/_backend/private/events.ts +++ b/supabase/functions/_backend/private/events.ts @@ -4,6 +4,7 @@ import type { MiddlewareKeyVariables } from '../utils/hono.ts' import type { BentoTrackingPayload } from '../utils/tracking.ts' import { Hono } from 'hono/tiny' import { BUILDER_RECOVERY_MILESTONES, buildBuilderOnboardingBentoEvent } from '../utils/builder_onboarding_recovery.ts' +import { BUNDLE_INCOMPATIBLE_EVENT, buildBundleCompatibilityBentoEvent } from '../utils/bundle_compatibility_recovery.ts' import { BRES, parseBody, quickError, simpleError, useCors } from '../utils/hono.ts' import { middlewareV2 } from '../utils/hono_middleware.ts' import { cloudlog } from '../utils/logging.ts' @@ -36,6 +37,16 @@ function isTrackingV2(version: unknown) { return version === 2 || version === '2' } +// Coerce a tag/DB value (string id, numeric/bigint id, or missing) into a +// non-empty string id or undefined — keeps the Bento payload *_id fields clean. +function toIdString(value: unknown): string | undefined { + if (typeof value === 'string') + return value.length > 0 ? value : undefined + if (typeof value === 'number' || typeof value === 'bigint') + return String(value) + return undefined +} + async function resolveTrackingUserId( c: Context, requestedUserId: string | undefined, @@ -253,8 +264,58 @@ app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => { } } + // Bundle compatibility failure (capgo bundle upload / bundle compatibility): + // when the CLI reports an incompatible bundle, emit a Bento signal so a + // lifecycle automation can react. Mirrors the builder block above; resolves + // org/app names + the freshly created version id for the payload. + let bundleIncompatibleBentoEvent: BentoTrackingPayload | undefined + if (onboardingOrgId && appId && trackedBody.event === BUNDLE_INCOMPATIBLE_EVENT) { + const tags = trackedBody.tags ?? {} + const versionNewName = typeof tags.version_new_name === 'string' && tags.version_new_name.length > 0 + ? tags.version_new_name + : undefined + const [orgResult, appResult] = await Promise.all([ + supabase.from('orgs').select('id, name').eq('id', onboardingOrgId).single(), + supabase.from('apps').select('name').eq('app_id', appId).single(), + ]) + if (orgResult.error || appResult.error) { + // Best-effort signal: never fail the CLI's request, and don't emit a Bento + // event with empty org/app context. Log and skip instead. + cloudlog({ requestId: c.get('requestId'), message: 'bundle incompatible bento lookup failed; skipping signal', org: orgResult.error, app: appResult.error }) + } + else { + // The upload flow sends only version_new_name; resolve its id here (the + // version exists by the time this event is sent). The command flow uploads + // nothing, so versionNewName is absent and version_new_id stays empty. + let versionNewId: string | undefined + if (versionNewName) { + const { data: versionNewData } = await supabase + .from('app_versions') + .select('id') + .eq('app_id', appId) + .eq('name', versionNewName) + .maybeSingle() + versionNewId = toIdString(versionNewData?.id) + } + const versionOldId = toIdString(tags.version_old_id) + bundleIncompatibleBentoEvent = buildBundleCompatibilityBentoEvent({ + event: trackedBody.event, + orgId: onboardingOrgId, + appId, + channel: typeof tags.channel === 'string' ? tags.channel : undefined, + source: typeof tags.source === 'string' ? tags.source : undefined, + versionNewId, + versionNewName, + versionOldId, + versionOldName: typeof tags.version_old_name === 'string' ? tags.version_old_name : undefined, + orgName: orgResult.data?.name ?? undefined, + appName: appResult.data?.name ?? undefined, + }) + } + } + // Exactly one of these is ever set (distinct event names); `??` picks the active one. - const bentoEvent = onboardingBentoEvent ?? builderBentoEvent + const bentoEvent = onboardingBentoEvent ?? builderBentoEvent ?? bundleIncompatibleBentoEvent await sendEventToTracking(c, { ...trackedBody, bento: bentoEvent, diff --git a/supabase/functions/_backend/utils/bundle_compatibility_recovery.ts b/supabase/functions/_backend/utils/bundle_compatibility_recovery.ts new file mode 100644 index 0000000000..045bb8ba13 --- /dev/null +++ b/supabase/functions/_backend/utils/bundle_compatibility_recovery.ts @@ -0,0 +1,79 @@ +import type { BentoTrackingPayload } from './tracking.ts' + +/** + * The CLI emits a `Bundle Incompatible` tracking event whenever a bundle's + * native dependencies don't match the version currently live on the target + * channel. It fires from two flows: + * - `bundle upload` (after the new version is created), and + * - the standalone `capgo bundle compatibility` command. + * + * We turn that into a Bento signal event so a lifecycle automation can react + * (e.g. nudge the org toward a native rebuild / Capgo Builder). Delivery and + * email gating are handled by `sendNotifToOrgMembers` via the dedicated + * `bundle_incompatible` preference key. Mirrors `buildBuilderOnboardingBentoEvent`. + */ +export const BUNDLE_INCOMPATIBLE_EVENT = 'Bundle Incompatible' + +export interface BundleCompatibilityBentoInput { + /** The incoming tracking event name (must be 'Bundle Incompatible'). */ + event: string + orgId: string | undefined + appId: string | undefined + /** Channel the bundle was checked against. */ + channel: string | undefined + /** Which flow detected the incompatibility: 'upload' | 'command'. */ + source: string | undefined + /** + * New bundle being uploaded. Empty for the standalone command, which uploads + * nothing — only the upload flow has a freshly created version. + */ + versionNewId: string | undefined + versionNewName: string | undefined + /** Version currently live on the channel that the bundle was compared against. */ + versionOldId: string | undefined + versionOldName: string | undefined + orgName: string | undefined + appName: string | undefined +} + +/** + * Pure: decide whether this event should emit a Bento signal and build its + * payload. Returns undefined when nothing should be emitted (wrong event name, + * or missing org/app context). + */ +export function buildBundleCompatibilityBentoEvent(input: BundleCompatibilityBentoInput): BentoTrackingPayload | undefined { + if (input.event !== BUNDLE_INCOMPATIBLE_EVENT) + return undefined + if (!input.orgId || !input.appId) + return undefined + + const source = input.source ?? 'unknown' + const channel = input.channel ?? '' + const versionNewName = input.versionNewName ?? '' + const versionOldName = input.versionOldName ?? '' + + return { + event: 'bundle_incompatible', + // Dedicated key — independent from other bundle/OTA email preferences. + preferenceKey: 'bundle_incompatible', + // Permanent per app+channel+version claim (no reopening cron window): repeated + // incompatible uploads / `bundle compatibility` checks of the SAME version must + // not re-email org admins. A genuinely new version has a different uniqId and + // notifies on its own; the old version is the fallback for the command flow + // (which uploads no new bundle). + once: true, + uniqId: `bundle_incompatible:${input.appId}:${channel}:${versionNewName || versionOldName}`, + data: { + org_id: input.orgId, + org_name: input.orgName ?? '', + app_id: input.appId, + app_name: input.appName ?? '', + channel, + source, + version_new_id: input.versionNewId ?? '', + version_new_name: versionNewName, + version_old_id: input.versionOldId ?? '', + version_old_name: versionOldName, + }, + } +} diff --git a/supabase/functions/_backend/utils/org_email_notifications.ts b/supabase/functions/_backend/utils/org_email_notifications.ts index 32c66f13dc..437a7a10bf 100644 --- a/supabase/functions/_backend/utils/org_email_notifications.ts +++ b/supabase/functions/_backend/utils/org_email_notifications.ts @@ -77,6 +77,7 @@ export type EmailPreferenceKey | 'channel_self_rejected' | 'daily_fail_ratio' | 'cli_realtime_feed' + | 'bundle_incompatible' export interface EmailPreferences { usage_limit?: boolean @@ -93,6 +94,7 @@ export interface EmailPreferences { channel_self_rejected?: boolean daily_fail_ratio?: boolean cli_realtime_feed?: boolean + bundle_incompatible?: boolean } interface OrgWithPreferences { diff --git a/supabase/functions/_backend/utils/tracking.ts b/supabase/functions/_backend/utils/tracking.ts index 5e0fa4d4a4..81ba180c87 100644 --- a/supabase/functions/_backend/utils/tracking.ts +++ b/supabase/functions/_backend/utils/tracking.ts @@ -3,15 +3,22 @@ import type { Context } from 'hono' import type { PostHogGroups } from './posthog.ts' import { cloudlogErr, serializeError } from './logging.ts' import { logsnag } from './logsnag.ts' -import { sendNotifToOrgMembers } from './org_email_notifications.ts' +import { sendNotifToOrgMembers, sendNotifToOrgMembersOnce } from './org_email_notifications.ts' import { getDrizzleClient, getPgClient } from './pg.ts' import { trackPosthogEvent } from './posthog.ts' import { backgroundTask } from './utils.ts' export interface BentoTrackingPayload { - cron: string + /** Cron window for the throttle/dedupe. Used only when `once` is not set. */ + cron?: string data: Record event: string + /** + * Send at most ONE notification ever per (event, org, uniqId) via a permanent + * claim instead of a reopening cron window. Use for per-entity alerts that must + * not re-fire on retries (e.g. an incompatible bundle version). Ignores `cron`. + */ + once?: boolean preferenceKey: import('./org_email_notifications.ts').EmailPreferenceKey uniqId: string } @@ -97,16 +104,31 @@ async function executeBentoTracking(c: Context, payload: SendEventToTrackingPayl await runTrackedCall(c, 'bento', async () => { const pgClient = getPgClient(c, true) try { - await sendNotifToOrgMembers( - c, - bento.event, - bento.preferenceKey, - bento.data, - orgId, - bento.uniqId, - bento.cron, - getDrizzleClient(pgClient), - ) + if (bento.once) { + // Permanent per-(event, org, uniqId) claim: per-entity alerts (e.g. an + // incompatible bundle version) must not re-email org admins on retries. + await sendNotifToOrgMembersOnce( + c, + bento.event, + bento.preferenceKey, + bento.data, + orgId, + bento.uniqId, + getDrizzleClient(pgClient), + ) + } + else { + await sendNotifToOrgMembers( + c, + bento.event, + bento.preferenceKey, + bento.data, + orgId, + bento.uniqId, + bento.cron ?? '* * * * *', + getDrizzleClient(pgClient), + ) + } } finally { await pgClient.end() diff --git a/supabase/functions/_backend/utils/user_preferences.ts b/supabase/functions/_backend/utils/user_preferences.ts index 252dd8ee16..e3a1fa7508 100644 --- a/supabase/functions/_backend/utils/user_preferences.ts +++ b/supabase/functions/_backend/utils/user_preferences.ts @@ -25,6 +25,7 @@ const EMAIL_PREF_DISABLED_TAGS: Record = { channel_self_rejected: 'channel_self_rejected_disabled', daily_fail_ratio: 'daily_fail_ratio_disabled', cli_realtime_feed: 'cli_realtime_feed_disabled', + bundle_incompatible: 'bundle_incompatible_disabled', } const ALL_LEGACY_TAGS = [NOTIFICATION_TAG, NEWSLETTER_TAG] diff --git a/supabase/migrations/20260530114525_add_bundle_incompatible_pref.sql b/supabase/migrations/20260530114525_add_bundle_incompatible_pref.sql new file mode 100644 index 0000000000..7320a308ad --- /dev/null +++ b/supabase/migrations/20260530114525_add_bundle_incompatible_pref.sql @@ -0,0 +1,16 @@ +-- Add bundle_incompatible preference for users and set default to true +-- (Emailed when an uploaded bundle / `capgo bundle compatibility` check is +-- incompatible with the channel's live native packages — separate from the +-- bundle_created / bundle_deployed keys) + +-- Backfill existing users who already have email_preferences set +UPDATE public.users +SET email_preferences = email_preferences || '{"bundle_incompatible": true}'::jsonb +WHERE email_preferences IS NOT NULL + AND NOT (email_preferences ? 'bundle_incompatible'); + +-- Update the column default to include the new key +ALTER TABLE public.users +ALTER COLUMN email_preferences SET DEFAULT '{"usage_limit": true, "credit_usage": true, "onboarding": true, "builder_onboarding": true, "weekly_stats": true, "monthly_stats": true, "billing_period_stats": true, "deploy_stats_24h": true, "bundle_created": true, "bundle_deployed": true, "device_error": true, "channel_self_rejected": true, "cli_realtime_feed": true, "bundle_incompatible": true}'::jsonb; + +COMMENT ON COLUMN public.users.email_preferences IS 'Per-user email notification preferences. Keys: usage_limit, credit_usage, onboarding, builder_onboarding, weekly_stats, monthly_stats, billing_period_stats, deploy_stats_24h, bundle_created, bundle_deployed, device_error, channel_self_rejected, cli_realtime_feed, bundle_incompatible. Values are booleans.'; diff --git a/tests/bundle-compatibility-recovery.unit.test.ts b/tests/bundle-compatibility-recovery.unit.test.ts new file mode 100644 index 0000000000..2dc4743a1f --- /dev/null +++ b/tests/bundle-compatibility-recovery.unit.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { BUNDLE_INCOMPATIBLE_EVENT, buildBundleCompatibilityBentoEvent } from '../supabase/functions/_backend/utils/bundle_compatibility_recovery.ts' + +const base = { + event: BUNDLE_INCOMPATIBLE_EVENT, + orgId: 'org-1', + appId: 'com.demo.app', + channel: 'production', + source: 'upload', + versionNewId: '101', + versionNewName: '1.0.1', + versionOldId: '100', + versionOldName: '1.0.0', + orgName: 'Demo Org', + appName: 'Demo', +} + +describe('buildBundleCompatibilityBentoEvent', () => { + it.concurrent('exposes the trigger event name', () => { + expect(BUNDLE_INCOMPATIBLE_EVENT).toBe('Bundle Incompatible') + }) + + it.concurrent('builds a full payload for an incompatible upload', () => { + const r = buildBundleCompatibilityBentoEvent(base) + expect(r).toBeDefined() + expect(r!.event).toBe('bundle_incompatible') + expect(r!.preferenceKey).toBe('bundle_incompatible') + // Permanent per-version dedupe (no reopening cron window), so retries of the + // same incompatible version don't re-email org admins. + expect(r!.once).toBe(true) + expect(r!.cron).toBeUndefined() + // uniqId keys off the new version for uploads. + expect(r!.uniqId).toBe('bundle_incompatible:com.demo.app:production:1.0.1') + expect(r!.data).toMatchObject({ + org_id: 'org-1', + org_name: 'Demo Org', + app_id: 'com.demo.app', + app_name: 'Demo', + channel: 'production', + source: 'upload', + version_new_id: '101', + version_new_name: '1.0.1', + version_old_id: '100', + version_old_name: '1.0.0', + }) + }) + + it.concurrent('falls back to the old version in uniqId for the command flow (no new version)', () => { + const r = buildBundleCompatibilityBentoEvent({ ...base, source: 'command', versionNewId: undefined, versionNewName: undefined }) + expect(r).toBeDefined() + expect(r!.uniqId).toBe('bundle_incompatible:com.demo.app:production:1.0.0') + expect(r!.data.source).toBe('command') + expect(r!.data.version_new_id).toBe('') + expect(r!.data.version_new_name).toBe('') + expect(r!.data.version_old_id).toBe('100') + expect(r!.data.version_old_name).toBe('1.0.0') + }) + + it.concurrent('returns undefined for other event names', () => { + expect(buildBundleCompatibilityBentoEvent({ ...base, event: 'Bundle Upload Compatibility Checked' })).toBeUndefined() + }) + + it.concurrent('returns undefined when org or app id is missing', () => { + expect(buildBundleCompatibilityBentoEvent({ ...base, orgId: undefined })).toBeUndefined() + expect(buildBundleCompatibilityBentoEvent({ ...base, appId: undefined })).toBeUndefined() + }) + + it.concurrent('defaults missing fields to safe empties', () => { + const r = buildBundleCompatibilityBentoEvent({ + ...base, + source: undefined, + channel: undefined, + orgName: undefined, + appName: undefined, + versionNewId: undefined, + versionNewName: undefined, + versionOldId: undefined, + versionOldName: undefined, + }) + expect(r).toBeDefined() + expect(r!.data.source).toBe('unknown') + expect(r!.data.channel).toBe('') + expect(r!.data.org_name).toBe('') + expect(r!.data.app_name).toBe('') + expect(r!.data.version_new_id).toBe('') + expect(r!.data.version_old_id).toBe('') + // No version names left to key off; uniqId trails with empty segments. + expect(r!.uniqId).toBe('bundle_incompatible:com.demo.app::') + }) +})