diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index cae10ebec9..4ffc23ebe5 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -2391,6 +2391,7 @@ export type Database = { ban_time: string | null country: string | null created_at: string | null + created_via_invite: boolean email: string email_preferences: Json enable_notifications: boolean @@ -2405,6 +2406,7 @@ export type Database = { ban_time?: string | null country?: string | null created_at?: string | null + created_via_invite?: boolean email: string email_preferences?: Json enable_notifications?: boolean @@ -2419,6 +2421,7 @@ export type Database = { ban_time?: string | null country?: string | null created_at?: string | null + created_via_invite?: boolean email?: string email_preferences?: Json enable_notifications?: boolean diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index bdb24eafd1..d0c922921c 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -81,6 +81,16 @@ function isUserAlreadyExistsAuthError(err: unknown): boolean { ) } +function isMissingCreatedViaInviteColumnError(err: unknown): boolean { + const anyErr = err as any + const code = String(anyErr?.code ?? '').toUpperCase() + const msg = String(anyErr?.message ?? '').toLowerCase() + + // PostgREST returns schema cache errors as PGRST204. + // Some environments may surface a Postgres undefined_column code (42703). + return code === 'PGRST204' || code === '42703' || msg.includes('created_via_invite') +} + async function rollbackCreatedUser(c: Parameters[0], userId: string) { // Best-effort rollback so users can retry the invite flow if something fails mid-way. const admin = useSupabaseAdmin(c) @@ -106,6 +116,7 @@ async function rollbackCreatedUser(c: Parameters[0], us } async function ensurePublicUserRowExists( + c: Parameters[0], supabaseAdmin: ReturnType, userId: string, invitation: any, @@ -123,14 +134,36 @@ async function ensurePublicUserRowExists( if (existingRows && existingRows.length > 0) return - const { error: insertError } = await supabaseAdmin.from('users').insert({ + const insertPayload = { id: userId, email: invitation.email, first_name: invitation.first_name, last_name: invitation.last_name, enable_notifications: true, opt_for_newsletters: optForNewsletters, - }) + created_via_invite: true, + } + + let { error: insertError } = await supabaseAdmin.from('users').insert(insertPayload) + + // Log any initial error for observability during rollout + if (insertError) { + cloudlog({ + requestId: c.get('requestId'), + message: 'ensurePublicUserRowExists: initial insert error', + error: insertError, + }) + } + + // Backward compatible rollout: if the column doesn't exist yet, retry without it. + if (isMissingCreatedViaInviteColumnError(insertError)) { + cloudlog({ + requestId: c.get('requestId'), + message: 'ensurePublicUserRowExists: created_via_invite column missing, retrying without it', + }) + const { created_via_invite: _createdViaInvite, ...fallbackPayload } = insertPayload + ;({ error: insertError } = await supabaseAdmin.from('users').insert(fallbackPayload)) + } if (insertError) { return quickError(500, 'failed_to_accept_invitation', 'Failed to create user row', { error: insertError.message }) @@ -380,7 +413,7 @@ app.post('/', async (c) => { }) if (!sessionError && session.user?.id) { - await ensurePublicUserRowExists(supabaseAdmin, session.user.id, invitation, body.opt_for_newsletters) + await ensurePublicUserRowExists(c, supabaseAdmin, session.user.id, invitation, body.opt_for_newsletters) await ensureOrgMembership(supabaseAdmin, session.user.id, invitation, org) const { error: tmpUserDeleteError } = await supabaseAdmin.from('tmp_users').delete().eq('invite_magic_string', body.magic_invite_string) @@ -404,14 +437,39 @@ app.post('/', async (c) => { let didRollback = false try { // TODO: improve error handling - const { error: userNormalTableError, data } = await supabaseAdmin.from('users').insert({ + const insertUserPayload = { id: user.user.id, email: invitation.email, first_name: invitation.first_name, last_name: invitation.last_name, enable_notifications: true, opt_for_newsletters: body.opt_for_newsletters, - }).select().single() + created_via_invite: true, + } + + let { + error: userNormalTableError, + data, + } = await supabaseAdmin.from('users').insert(insertUserPayload).select().single() + + // Log any initial error for observability during rollout + if (userNormalTableError) { + cloudlog({ + requestId: c.get('requestId'), + message: 'accept_invitation: initial user insert error', + error: userNormalTableError, + }) + } + + // Backward compatible rollout: if the column doesn't exist yet, retry without it. + if (isMissingCreatedViaInviteColumnError(userNormalTableError)) { + cloudlog({ + requestId: c.get('requestId'), + message: 'accept_invitation: created_via_invite column missing, retrying without it', + }) + const { created_via_invite: _createdViaInvite, ...fallbackPayload } = insertUserPayload + ;({ error: userNormalTableError, data } = await supabaseAdmin.from('users').insert(fallbackPayload).select().single()) + } if (userNormalTableError) { didRollback = true diff --git a/supabase/functions/_backend/triggers/logsnag_insights.ts b/supabase/functions/_backend/triggers/logsnag_insights.ts index 0abea9a7cb..8238a2844a 100644 --- a/supabase/functions/_backend/triggers/logsnag_insights.ts +++ b/supabase/functions/_backend/triggers/logsnag_insights.ts @@ -407,15 +407,35 @@ function getStats(c: Context): GlobalStats { updates_last_month: readLastMonthUpdatesCF(c), devices_last_month: readLastMonthDevicesCF(c), devices_by_platform: readLastMonthDevicesByPlatformCF(c), - registers_today: supabase - .from('users') - .select('id', { count: 'exact', head: true }) - .gte('created_at', last24h) - .then((res) => { - if (res.error) - cloudlog({ requestId: c.get('requestId'), message: 'registers_today error', error: res.error }) - return res.count ?? 0 - }), + registers_today: (async () => { + // TODO: Remove backward-compat fallback once migration 20260209014020 is deployed to all environments. + // Backward compatible rollout: if the column doesn't exist yet, fall back to the legacy count. + const filtered = await supabase + .from('users') + .select('id', { count: 'exact', head: true }) + .gte('created_at', last24h) + .eq('created_via_invite', false) + + const filteredCode = String((filtered.error as any)?.code ?? '').toUpperCase() + if (filteredCode === 'PGRST204' || filteredCode === '42703' || filtered.error?.message?.toLowerCase().includes('created_via_invite')) { + cloudlog({ + requestId: c.get('requestId'), + message: 'registers_today: created_via_invite column missing, falling back to legacy count', + error: filtered.error, + }) + const legacy = await supabase + .from('users') + .select('id', { count: 'exact', head: true }) + .gte('created_at', last24h) + if (legacy.error) + cloudlog({ requestId: c.get('requestId'), message: 'registers_today legacy error', error: legacy.error }) + return legacy.count ?? 0 + } + + if (filtered.error) + cloudlog({ requestId: c.get('requestId'), message: 'registers_today error', error: filtered.error }) + return filtered.count ?? 0 + })(), bundle_storage_gb: supabase .rpc('total_bundle_storage_bytes') .then((res) => { diff --git a/supabase/functions/_backend/triggers/on_user_create.ts b/supabase/functions/_backend/triggers/on_user_create.ts index a8ddb8fc03..963583ea76 100644 --- a/supabase/functions/_backend/triggers/on_user_create.ts +++ b/supabase/functions/_backend/triggers/on_user_create.ts @@ -15,13 +15,23 @@ app.post('/', middlewareAPISecret, triggerValidator('users', 'INSERT'), async (c await createApiKey(c, record.id) cloudlog({ requestId: c.get('requestId'), message: 'createCustomer stripe' }) await syncUserPreferenceTags(c, record.email, record) - const LogSnag = logsnag(c) - await LogSnag.track({ - channel: 'user-register', - event: 'User Joined', - icon: '🎉', - user_id: record.id, - notify: false, - }).catch() + // "User Joined" should represent a self-signup (technical user expected to onboard), + // not an account created by accepting an org invite. + if (!record.created_via_invite) { + const LogSnag = logsnag(c) + await LogSnag.track({ + channel: 'user-register', + event: 'User Joined', + icon: '🎉', + user_id: record.id, + notify: false, + }).catch((error) => { + cloudlog({ + requestId: c.get('requestId'), + message: 'LogSnag.track user-register failed', + error, + }) + }) + } return c.json(BRES) }) diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index cae10ebec9..4ffc23ebe5 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -2391,6 +2391,7 @@ export type Database = { ban_time: string | null country: string | null created_at: string | null + created_via_invite: boolean email: string email_preferences: Json enable_notifications: boolean @@ -2405,6 +2406,7 @@ export type Database = { ban_time?: string | null country?: string | null created_at?: string | null + created_via_invite?: boolean email: string email_preferences?: Json enable_notifications?: boolean @@ -2419,6 +2421,7 @@ export type Database = { ban_time?: string | null country?: string | null created_at?: string | null + created_via_invite?: boolean email?: string email_preferences?: Json enable_notifications?: boolean diff --git a/supabase/migrations/20260209014020_user_created_via_invite.sql b/supabase/migrations/20260209014020_user_created_via_invite.sql new file mode 100644 index 0000000000..f5b0ee41a2 --- /dev/null +++ b/supabase/migrations/20260209014020_user_created_via_invite.sql @@ -0,0 +1,7 @@ +-- Track whether a user account was created via the invitation flow. +-- This is used for internal onboarding metrics ("User Joined") so we can exclude invited members. +ALTER TABLE "public"."users" +ADD COLUMN IF NOT EXISTS "created_via_invite" boolean NOT NULL DEFAULT false; + +COMMENT ON COLUMN "public"."users"."created_via_invite" IS +'True when the account was created through /private/accept_invitation (invited members), false for normal self-signups.'; diff --git a/tests/user-created-via-invite.test.ts b/tests/user-created-via-invite.test.ts new file mode 100644 index 0000000000..5bd822f314 --- /dev/null +++ b/tests/user-created-via-invite.test.ts @@ -0,0 +1,57 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { USER_PASSWORD_HASH, executeSQL } from './test-utils.ts' + +describe('users.created_via_invite', () => { + it.concurrent('defaults to false for normal inserts (self-signup semantics)', async () => { + const userId = randomUUID() + const email = `user-created-via-invite-default-${randomUUID()}@test.com` + + await executeSQL( + `INSERT INTO auth.users (id, email, encrypted_password, email_confirmed_at, created_at, updated_at, raw_user_meta_data) + VALUES ($1, $2, $3, NOW(), NOW(), NOW(), '{}'::jsonb) + ON CONFLICT (id) DO NOTHING`, + [userId, email, USER_PASSWORD_HASH], + ) + + await executeSQL( + `INSERT INTO public.users (id, email) + VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email`, + [userId, email], + ) + + const rows = await executeSQL( + 'SELECT created_via_invite FROM public.users WHERE id = $1', + [userId], + ) + expect(rows[0]?.created_via_invite).toBe(false) + }) + + it.concurrent('can be explicitly set true for invite-created accounts', async () => { + const userId = randomUUID() + const email = `user-created-via-invite-true-${randomUUID()}@test.com` + + await executeSQL( + `INSERT INTO auth.users (id, email, encrypted_password, email_confirmed_at, created_at, updated_at, raw_user_meta_data) + VALUES ($1, $2, $3, NOW(), NOW(), NOW(), '{}'::jsonb) + ON CONFLICT (id) DO NOTHING`, + [userId, email, USER_PASSWORD_HASH], + ) + + await executeSQL( + `INSERT INTO public.users (id, email, created_via_invite) + VALUES ($1, $2, $3) + ON CONFLICT (id) DO UPDATE SET + email = EXCLUDED.email, + created_via_invite = EXCLUDED.created_via_invite`, + [userId, email, true], + ) + + const rows = await executeSQL( + 'SELECT created_via_invite FROM public.users WHERE id = $1', + [userId], + ) + expect(rows[0]?.created_via_invite).toBe(true) + }) +})