Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/types/supabase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
68 changes: 63 additions & 5 deletions supabase/functions/_backend/private/accept_invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useSupabaseAdmin>[0], userId: string) {
// Best-effort rollback so users can retry the invite flow if something fails mid-way.
const admin = useSupabaseAdmin(c)
Expand All @@ -106,6 +116,7 @@ async function rollbackCreatedUser(c: Parameters<typeof useSupabaseAdmin>[0], us
}

async function ensurePublicUserRowExists(
c: Parameters<typeof useSupabaseAdmin>[0],
supabaseAdmin: ReturnType<typeof useSupabaseAdmin>,
userId: string,
invitation: any,
Expand All @@ -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))
}
Comment on lines 147 to 166
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backward-compat fallback (retry insert without created_via_invite) is silent. When this triggers, it changes metrics semantics and makes it hard to diagnose schema/cache mismatches; consider logging the original insertError (at least once / at debug level) before retrying.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback


if (insertError) {
return quickError(500, 'failed_to_accept_invitation', 'Failed to create user row', { error: insertError.message })
Expand Down Expand Up @@ -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)
Expand All @@ -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())
}
Comment on lines 453 to 472
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: the silent fallback on created_via_invite insert errors makes it difficult to detect when invited users are being recorded without the flag. Logging the initial userNormalTableError before retrying would improve observability during rollout.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines 440 to 472
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change introduces new persisted behavior (marking invite-created accounts via created_via_invite) that affects onboarding metrics. There are tests for /private/accept_invitation error cases, but no test covering the successful path asserting the users.created_via_invite flag is set (and remains false for normal signups). Adding a success-path test would prevent regressions.

Copilot generated this review using guidance from repository custom instructions.

if (userNormalTableError) {
didRollback = true
Expand Down
38 changes: 29 additions & 9 deletions supabase/functions/_backend/triggers/logsnag_insights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
26 changes: 18 additions & 8 deletions supabase/functions/_backend/triggers/on_user_create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
3 changes: 3 additions & 0 deletions supabase/functions/_backend/utils/supabase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.';
57 changes: 57 additions & 0 deletions tests/user-created-via-invite.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})