From 341654fc5c74fcf34232ed930c9ab95f85f34c39 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 9 Feb 2026 01:53:36 +0000 Subject: [PATCH 1/5] fix(metrics): exclude invited users from User Joined --- src/types/supabase.types.ts | 3 ++ .../_backend/private/accept_invitation.ts | 29 ++++++++++++++--- .../_backend/triggers/logsnag_insights.ts | 31 +++++++++++++------ .../_backend/triggers/on_user_create.ts | 20 +++++++----- .../_backend/utils/supabase.types.ts | 3 ++ ...20260209014020_user_created_via_invite.sql | 7 +++++ 6 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 supabase/migrations/20260209014020_user_created_via_invite.sql 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..19315961fa 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -123,14 +123,23 @@ 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) + + // Backward compatible rollout: if the column doesn't exist yet, retry without it. + if (insertError?.message?.toLowerCase().includes('created_via_invite')) { + 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 }) @@ -404,14 +413,26 @@ 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() + + // Backward compatible rollout: if the column doesn't exist yet, retry without it. + if (userNormalTableError?.message?.toLowerCase().includes('created_via_invite')) { + 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..19941be119 100644 --- a/supabase/functions/_backend/triggers/logsnag_insights.ts +++ b/supabase/functions/_backend/triggers/logsnag_insights.ts @@ -407,15 +407,28 @@ 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 () => { + // 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) + + if (filtered.error?.message?.toLowerCase().includes('created_via_invite')) { + 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..30e5b3048b 100644 --- a/supabase/functions/_backend/triggers/on_user_create.ts +++ b/supabase/functions/_backend/triggers/on_user_create.ts @@ -15,13 +15,17 @@ 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() + } 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.'; From 64f0061eb689f17d3886ee29231d91ecbe42e019 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 9 Feb 2026 02:32:23 +0000 Subject: [PATCH 2/5] test(metrics): add created_via_invite coverage --- .../_backend/private/accept_invitation.ts | 13 ++++++- .../_backend/triggers/logsnag_insights.ts | 5 +++ .../_backend/triggers/on_user_create.ts | 8 +++- tests/user-created-via-invite.test.ts | 38 +++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 tests/user-created-via-invite.test.ts diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index 19315961fa..8c06cad116 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -106,6 +106,7 @@ async function rollbackCreatedUser(c: Parameters[0], us } async function ensurePublicUserRowExists( + c: Parameters[0], supabaseAdmin: ReturnType, userId: string, invitation: any, @@ -137,6 +138,11 @@ async function ensurePublicUserRowExists( // Backward compatible rollout: if the column doesn't exist yet, retry without it. if (insertError?.message?.toLowerCase().includes('created_via_invite')) { + cloudlog({ + requestId: c.get('requestId'), + message: 'ensurePublicUserRowExists: created_via_invite column missing, retrying without it', + error: insertError, + }) const { created_via_invite: _createdViaInvite, ...fallbackPayload } = insertPayload ;({ error: insertError } = await supabaseAdmin.from('users').insert(fallbackPayload)) } @@ -389,7 +395,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) @@ -430,6 +436,11 @@ app.post('/', async (c) => { // Backward compatible rollout: if the column doesn't exist yet, retry without it. if (userNormalTableError?.message?.toLowerCase().includes('created_via_invite')) { + cloudlog({ + requestId: c.get('requestId'), + message: 'accept_invitation: created_via_invite column missing, retrying without it', + error: userNormalTableError, + }) const { created_via_invite: _createdViaInvite, ...fallbackPayload } = insertUserPayload ;({ error: userNormalTableError, data } = await supabaseAdmin.from('users').insert(fallbackPayload).select().single()) } diff --git a/supabase/functions/_backend/triggers/logsnag_insights.ts b/supabase/functions/_backend/triggers/logsnag_insights.ts index 19941be119..7ff0ccf436 100644 --- a/supabase/functions/_backend/triggers/logsnag_insights.ts +++ b/supabase/functions/_backend/triggers/logsnag_insights.ts @@ -416,6 +416,11 @@ function getStats(c: Context): GlobalStats { .eq('created_via_invite', false) if (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 }) diff --git a/supabase/functions/_backend/triggers/on_user_create.ts b/supabase/functions/_backend/triggers/on_user_create.ts index 30e5b3048b..963583ea76 100644 --- a/supabase/functions/_backend/triggers/on_user_create.ts +++ b/supabase/functions/_backend/triggers/on_user_create.ts @@ -25,7 +25,13 @@ app.post('/', middlewareAPISecret, triggerValidator('users', 'INSERT'), async (c icon: '🎉', user_id: record.id, notify: false, - }).catch() + }).catch((error) => { + cloudlog({ + requestId: c.get('requestId'), + message: 'LogSnag.track user-register failed', + error, + }) + }) } return c.json(BRES) }) diff --git a/tests/user-created-via-invite.test.ts b/tests/user-created-via-invite.test.ts new file mode 100644 index 0000000000..529c5c66b3 --- /dev/null +++ b/tests/user-created-via-invite.test.ts @@ -0,0 +1,38 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { 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 public.users (id, email) VALUES ($1, $2)', + [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 public.users (id, email, created_via_invite) VALUES ($1, $2, $3)', + [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) + }) +}) + From b6bbbbda7bb29811812ef8785d2d2d602091dfd3 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 9 Feb 2026 02:43:02 +0000 Subject: [PATCH 3/5] chore(metrics): harden created_via_invite fallback --- .../_backend/private/accept_invitation.ts | 14 ++++++++++++-- .../_backend/triggers/logsnag_insights.ts | 4 +++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index 8c06cad116..9165ee06e8 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) @@ -137,7 +147,7 @@ async function ensurePublicUserRowExists( let { error: insertError } = await supabaseAdmin.from('users').insert(insertPayload) // Backward compatible rollout: if the column doesn't exist yet, retry without it. - if (insertError?.message?.toLowerCase().includes('created_via_invite')) { + if (isMissingCreatedViaInviteColumnError(insertError)) { cloudlog({ requestId: c.get('requestId'), message: 'ensurePublicUserRowExists: created_via_invite column missing, retrying without it', @@ -435,7 +445,7 @@ app.post('/', async (c) => { } = await supabaseAdmin.from('users').insert(insertUserPayload).select().single() // Backward compatible rollout: if the column doesn't exist yet, retry without it. - if (userNormalTableError?.message?.toLowerCase().includes('created_via_invite')) { + if (isMissingCreatedViaInviteColumnError(userNormalTableError)) { cloudlog({ requestId: c.get('requestId'), message: 'accept_invitation: created_via_invite column missing, retrying without it', diff --git a/supabase/functions/_backend/triggers/logsnag_insights.ts b/supabase/functions/_backend/triggers/logsnag_insights.ts index 7ff0ccf436..8238a2844a 100644 --- a/supabase/functions/_backend/triggers/logsnag_insights.ts +++ b/supabase/functions/_backend/triggers/logsnag_insights.ts @@ -408,6 +408,7 @@ function getStats(c: Context): GlobalStats { devices_last_month: readLastMonthDevicesCF(c), devices_by_platform: readLastMonthDevicesByPlatformCF(c), 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') @@ -415,7 +416,8 @@ function getStats(c: Context): GlobalStats { .gte('created_at', last24h) .eq('created_via_invite', false) - if (filtered.error?.message?.toLowerCase().includes('created_via_invite')) { + 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', From 6242daf961dddbf99dad3c4f8d50b6e950034620 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 9 Feb 2026 02:53:56 +0000 Subject: [PATCH 4/5] test: fix created_via_invite fk --- tests/user-created-via-invite.test.ts | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/tests/user-created-via-invite.test.ts b/tests/user-created-via-invite.test.ts index 529c5c66b3..5bd822f314 100644 --- a/tests/user-created-via-invite.test.ts +++ b/tests/user-created-via-invite.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import { describe, expect, it } from 'vitest' -import { executeSQL } from './test-utils.ts' +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 () => { @@ -8,7 +8,16 @@ describe('users.created_via_invite', () => { const email = `user-created-via-invite-default-${randomUUID()}@test.com` await executeSQL( - 'INSERT INTO public.users (id, email) VALUES ($1, $2)', + `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], ) @@ -24,7 +33,18 @@ describe('users.created_via_invite', () => { const email = `user-created-via-invite-true-${randomUUID()}@test.com` await executeSQL( - 'INSERT INTO public.users (id, email, created_via_invite) VALUES ($1, $2, $3)', + `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], ) @@ -35,4 +55,3 @@ describe('users.created_via_invite', () => { expect(rows[0]?.created_via_invite).toBe(true) }) }) - From 33195dfecae38f59f1dcc4f789a28ffc8be0557a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 03:13:58 +0000 Subject: [PATCH 5/5] Improve error logging observability for created_via_invite rollout (#1607) * Initial plan * feat: log all initial insert errors before retry decision Co-authored-by: riderx <4084527+riderx@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: riderx <4084527+riderx@users.noreply.github.com> --- .../_backend/private/accept_invitation.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index 9165ee06e8..d0c922921c 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -146,12 +146,20 @@ async function ensurePublicUserRowExists( 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', - error: insertError, }) const { created_via_invite: _createdViaInvite, ...fallbackPayload } = insertPayload ;({ error: insertError } = await supabaseAdmin.from('users').insert(fallbackPayload)) @@ -444,12 +452,20 @@ app.post('/', async (c) => { 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', - error: userNormalTableError, }) const { created_via_invite: _createdViaInvite, ...fallbackPayload } = insertUserPayload ;({ error: userNormalTableError, data } = await supabaseAdmin.from('users').insert(fallbackPayload).select().single())