From 512406837dc90092bc888e5d43ab6768816069be Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 5 Mar 2026 15:40:20 +0100 Subject: [PATCH] Update options in same API call along with user creation --- backend/api/src/create-user-and-profile.ts | 38 +++++++-- backend/api/src/update-options.ts | 99 ++++++++++++---------- common/src/api/schema.ts | 5 +- common/src/profiles/profile.ts | 37 +++----- web/pages/[username]/index.tsx | 44 +++++----- web/pages/signup.tsx | 35 ++++---- 6 files changed, 139 insertions(+), 119 deletions(-) diff --git a/backend/api/src/create-user-and-profile.ts b/backend/api/src/create-user-and-profile.ts index 4b798b8d..5bdc64e4 100644 --- a/backend/api/src/create-user-and-profile.ts +++ b/backend/api/src/create-user-and-profile.ts @@ -1,4 +1,5 @@ import {setLastOnlineTimeUser} from 'api/set-last-online-time' +import {setProfileOptions} from 'api/update-options' import {defaultLocale} from 'common/constants' import {sendDiscordMessage} from 'common/discord/core' import {DEPLOYED_WEB_URL} from 'common/envs/constants' @@ -9,7 +10,6 @@ import {PrivateUser} from 'common/user' import {getDefaultNotificationPreferences} from 'common/user-notification-preferences' import {cleanDisplayName} from 'common/util/clean-username' import {removeUndefinedProps} from 'common/util/object' -import {MINUTE_MS, sleep} from 'common/util/time' import {sendWelcomeEmail} from 'email/functions/helpers' import * as admin from 'firebase-admin' import {getIp, track} from 'shared/analytics' @@ -29,7 +29,17 @@ export const createUserAndProfile: APIHandler<'create-user-and-profile'> = async req, ) => { trimStrings(props) - const {deviceToken, locale = defaultLocale, username, name, link, profile} = props + const { + deviceToken, + locale = defaultLocale, + username, + name, + link, + profile, + interests, + causes, + work, + } = props await removePinnedUrlFromPhotoUrls(profile) const host = req.get('referer') @@ -57,7 +67,9 @@ export const createUserAndProfile: APIHandler<'create-user-and-profile'> = async } } - const {user, privateUser} = await pg.tx(async (tx) => { + // The pg.tx() call wraps several database operations in a single atomic transaction, + // ensuring they either all succeed or all fail together. + const {user, privateUser, newProfileRow} = await pg.tx(async (tx) => { const existingUser = await tx.oneOrNone('select id from users where id = $1', [auth.uid]) if (existingUser) { throw new APIError(403, 'User already exists', {userId: auth.uid}) @@ -102,14 +114,21 @@ export const createUserAndProfile: APIHandler<'create-user-and-profile'> = async const profileData = removeUndefinedProps(profile) - await insert(tx, 'profiles', { + const newProfileRow = await insert(tx, 'profiles', { user_id: auth.uid, ...profileData, }) + const profileId = newProfileRow.id + + await setProfileOptions(tx, profileId, auth.uid, 'interests', interests) + await setProfileOptions(tx, profileId, auth.uid, 'causes', causes) + await setProfileOptions(tx, profileId, auth.uid, 'work', work) + return { user: convertUser(newUserRow), privateUser: convertPrivateUser(newPrivateUserRow), + newProfileRow, } }) @@ -132,10 +151,6 @@ export const createUserAndProfile: APIHandler<'create-user-and-profile'> = async console.error('Failed to set last online time', e) } try { - // Let the user fill in the optional form with all their info and pictures before notifying discord of their arrival. - // So we can sse their full profile as soon as we get the notif on discord. And that allows OG to pull their pic for the link preview. - // Regardless, you need to wait for at least 5 seconds that the profile is fully in the db—otherwise ISR may cache "profile not created yet" - await sleep(MINUTE_MS) const message: string = `[**${user.name}**](${DEPLOYED_WEB_URL}/${user.username}) just created a profile` await sendDiscordMessage(message, 'members') } catch (e) { @@ -163,8 +178,15 @@ export const createUserAndProfile: APIHandler<'create-user-and-profile'> = async return { result: { + // include everything the frontend needs user, privateUser, + profile: { + ...newProfileRow, + interests: interests ?? [], + causes: causes ?? [], + work: work ?? [], + }, }, continue: continuation, } diff --git a/backend/api/src/update-options.ts b/backend/api/src/update-options.ts index 2cbcc0bd..5dfbd500 100644 --- a/backend/api/src/update-options.ts +++ b/backend/api/src/update-options.ts @@ -1,15 +1,19 @@ import {APIError, APIHandler} from 'api/helpers/endpoint' import {OPTION_TABLES} from 'common/profiles/constants' import {tryCatch} from 'common/util/try-catch' -import {createSupabaseDirectClient} from 'shared/supabase/init' +import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init' import {log} from 'shared/utils' -export const updateOptions: APIHandler<'update-options'> = async ({table, values}, auth) => { +export async function setProfileOptions( + tx: SupabaseDirectClient, + profileId: number, + userId: string, + table: 'interests' | 'causes' | 'work', + values: string[] | undefined | null, +) { if (!OPTION_TABLES.includes(table)) throw new APIError(400, 'Invalid table') - if (!values || !Array.isArray(values)) { - throw new APIError(400, 'No ids provided') - } + values = values ?? [] const idsWithNumbers = values.map((id) => { const numberId = Number(id) return isNaN(numberId) ? {isNumber: false, v: id} : {isNumber: true, v: numberId} @@ -23,6 +27,49 @@ export const updateOptions: APIHandler<'update-options'> = async ({table, values log('Updating profile options', {table, ids, names}) + const currentOptionsResult = await tx.manyOrNone<{id: string}>( + `SELECT option_id as id FROM profile_${table} WHERE profile_id = $1`, + [profileId], + ) + const currentOptions = currentOptionsResult.map((row) => row.id) + + const hasSameIds = currentOptions.sort().join(',') === ids.sort().join(',') + if (hasSameIds && !names.length) { + log(`Skipping /update-${table} because they are already the same`) + return + } + + // Add new options + for (const name of names) { + const row = await tx.one<{id: number}>( + `INSERT INTO ${table} (name, creator_id) + VALUES ($1, $2) + ON CONFLICT (name) DO UPDATE + SET name = ${table}.name + RETURNING id`, + [name, userId], + ) + ids.push(row.id) + } + + // Delete old options for this profile + await tx.none(`DELETE FROM profile_${table} WHERE profile_id = $1`, [profileId]) + + // Insert new option_ids + if (ids.length > 0) { + const valuesSql = ids.map((_, i) => `($1, $${i + 2})`).join(', ') + await tx.none(`INSERT INTO profile_${table} (profile_id, option_id) VALUES ${valuesSql}`, [ + profileId, + ...ids, + ]) + } +} + +export const updateOptions: APIHandler<'update-options'> = async ({table, values}, auth) => { + if (!values || !Array.isArray(values)) { + throw new APIError(400, 'No ids provided') + } + const pg = createSupabaseDirectClient() const profileIdResult = await pg.oneOrNone<{id: number}>( @@ -34,44 +81,8 @@ export const updateOptions: APIHandler<'update-options'> = async ({table, values const result = await tryCatch( pg.tx(async (t) => { - const currentOptionsResult = await t.manyOrNone<{id: string}>( - `SELECT option_id as id - FROM profile_${table} - WHERE profile_id = $1`, - [profileId], - ) - const currentOptions = currentOptionsResult.map((row) => row.id) - if (currentOptions.sort().join(',') === ids.sort().join(',') && !names?.length) { - log(`Skipping /update-${table} because they are already the same`) - return undefined - } - - // Add new options - for (const name of names || []) { - const row = await t.one<{id: number}>( - `INSERT INTO ${table} (name, creator_id) - VALUES ($1, $2) - ON CONFLICT (name) DO UPDATE - SET name = ${table}.name - RETURNING id`, - [name, auth.uid], - ) - ids.push(row.id) - } - - // Delete old options for this profile - await t.none(`DELETE FROM profile_${table} WHERE profile_id = $1`, [profileId]) - - // Insert new option_ids - if (ids.length > 0) { - const values = ids.map((id, i) => `($1, $${i + 2})`).join(', ') - await t.none(`INSERT INTO profile_${table} (profile_id, option_id) VALUES ${values}`, [ - profileId, - ...ids, - ]) - } - - return ids + await setProfileOptions(t, profileId, auth.uid, table, values) + return true }), ) @@ -80,5 +91,5 @@ export const updateOptions: APIHandler<'update-options'> = async ({table, values throw new APIError(500, 'Error updating profile options') } - return {updatedIds: result.data} + return {updatedIds: true} } diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 9579f956..24ba40db 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -194,7 +194,7 @@ export const API = (_apiTypeCheck = { method: 'POST', authed: true, rateLimited: true, - returns: {} as {user: User; privateUser: PrivateUser}, + returns: {} as {user: User; privateUser: PrivateUser; profile: any}, props: z .object({ deviceToken: z.string().optional(), @@ -203,6 +203,9 @@ export const API = (_apiTypeCheck = { name: z.string().min(1), link: z.record(z.string().nullable()).optional(), profile: combinedProfileSchema, + interests: arraybeSchema.optional(), + causes: arraybeSchema.optional(), + work: arraybeSchema.optional(), }) .strict(), summary: 'Create a new user and profile in a single transaction', diff --git a/common/src/profiles/profile.ts b/common/src/profiles/profile.ts index 2fa84eaa..faa5cf06 100644 --- a/common/src/profiles/profile.ts +++ b/common/src/profiles/profile.ts @@ -15,30 +15,21 @@ export const getProfileRow = async ( const profile = profileRes.data?.[0] if (!profile) return null - // Fetch interests - const interestsRes = await run( - db.from('profile_interests').select('interests(name, id)').eq('profile_id', profile.id), - ) - const interests = interestsRes.data?.map((row: any) => String(row.interests.id)) || [] + // Parallel instead of sequential + const [interestsRes, causesRes, workRes] = await Promise.all([ + run(db.from('profile_interests').select('interests(name, id)').eq('profile_id', profile.id)), + run(db.from('profile_causes').select('causes(name, id)').eq('profile_id', profile.id)), + run(db.from('profile_work').select('work(name, id)').eq('profile_id', profile.id)), + ]) - // Fetch causes - const causesRes = await run( - db.from('profile_causes').select('causes(name, id)').eq('profile_id', profile.id), - ) - const causes = causesRes.data?.map((row: any) => String(row.causes.id)) || [] - - // Fetch causes - const workRes = await run( - db.from('profile_work').select('work(name, id)').eq('profile_id', profile.id), - ) - const work = workRes.data?.map((row: any) => String(row.work.id)) || [] - - // console.debug({work, interests, causes}) - - return { + const result = { ...profile, - interests, - causes, - work, + interests: interestsRes.data?.map((r: any) => String(r.interests.id)) ?? [], + causes: causesRes.data?.map((r: any) => String(r.causes.id)) ?? [], + work: workRes.data?.map((r: any) => String(r.work.id)) ?? [], } + + // console.debug(result) + + return result } diff --git a/web/pages/[username]/index.tsx b/web/pages/[username]/index.tsx index 02249ac1..7d5f9b65 100644 --- a/web/pages/[username]/index.tsx +++ b/web/pages/[username]/index.tsx @@ -6,7 +6,6 @@ import {getProfileRow, ProfileRow} from 'common/profiles/profile' import {getUserForStaticProps} from 'common/supabase/users' import {User} from 'common/user' import {parseJsonContentToText} from 'common/util/parse' -import {sleep} from 'common/util/time' import {GetStaticPropsContext} from 'next' import Head from 'next/head' import {useRouter} from 'next/router' @@ -22,7 +21,7 @@ import {useSaveReferral} from 'web/hooks/use-save-referral' import {useTracking} from 'web/hooks/use-tracking' import {useUser} from 'web/hooks/use-user' import {db} from 'web/lib/supabase/db' -import {getPixelHeight} from 'web/lib/util/css' +import {safeLocalStorage} from 'web/lib/util/local' import {getPageData} from 'web/lib/util/page-data' import {isNativeMobile} from 'web/lib/util/webview' @@ -34,17 +33,7 @@ async function getUser(username: string) { } async function getProfile(userId: string) { - let profile - let i = 0 - while (!profile) { - profile = await getProfileRow(userId, db) - if (i > 0) await sleep(500) - i++ - if (i >= 40) { - break - } - } - debug(`Profile loaded after ${i} tries`) + const profile = await getProfileRow(userId, db) return profile } @@ -95,8 +84,7 @@ export const getStaticProps = async ( debug('No user') return { props: { - notFoundCustomText: - 'The profile you are looking for is not on this site... or perhaps you just mistyped?', + notFoundCustomText: null, }, revalidate: 1, } @@ -163,18 +151,28 @@ type ActiveUserPageProps = { } export default function UserPage(props: UserPageProps) { - // console.log('Starting UserPage in /[username]') - - useEffect(() => { - console.log('safe-area-inset-bottom:', getPixelHeight('safe-area-inset-bottom')) - console.log('safe-area-inset-top:', getPixelHeight('safe-area-inset-top')) - }, []) - const nativeMobile = isNativeMobile() const router = useRouter() const username = (nativeMobile ? router.query.username : props.username) as string - const [fetchedProps, setFetchedProps] = useState(props) const [loading, setLoading] = useState(nativeMobile) + const fromSignup = router?.query?.fromSignup === 'true' + + // Hydrate from localStorage if coming from registration, + // before any null checks that would block UserPageInner + const [fetchedProps, setFetchedProps] = useState(() => { + if (fromSignup) { + const fresh = safeLocalStorage?.getItem('freshSignup') + if (fresh) { + safeLocalStorage?.removeItem('freshSignup') + const {user, profile} = JSON.parse(fresh) + if (user && profile) { + debug('Using fresh profile from signup') + return {username: user.username, user, profile} + } + } + } + return props + }) console.log( 'UserPage state:', diff --git a/web/pages/signup.tsx b/web/pages/signup.tsx index 17098fcb..bb0b91f7 100644 --- a/web/pages/signup.tsx +++ b/web/pages/signup.tsx @@ -1,12 +1,9 @@ import {LOCALE_TO_LANGUAGE} from 'common/choices' -import {IS_DEPLOYED} from 'common/hosting/constants' import {ProfileWithoutUser} from 'common/profiles/profile' import {BaseUser} from 'common/user' -import {filterDefined} from 'common/util/array' import {cleanDisplayName, cleanUsername} from 'common/util/clean-username' import {removeNullOrUndefinedProps} from 'common/util/object' import {randomString} from 'common/util/random' -import {sleep} from 'common/util/time' import {useRouter} from 'next/router' import {useEffect, useState} from 'react' import toast, {Toaster} from 'react-hot-toast' @@ -27,6 +24,7 @@ import {clearOnboardingFlag} from 'web/lib/util/signup' export default function SignupPage() { const [step, setStep] = useState(0) + const [isSubmitting, setIsSubmitting] = useState(false) const router = useRouter() useTracking('view signup page') @@ -76,6 +74,7 @@ export default function SignupPage() { } const handleFinalSubmit = async () => { + setIsSubmitting(true) const referredByUsername = safeLocalStorage ? (safeLocalStorage.getItem(CACHED_REFERRAL_USERNAME_KEY) ?? undefined) : undefined @@ -95,44 +94,40 @@ export default function SignupPage() { deviceToken, link: baseUser.link, profile: otherProfileProps, + interests, + causes, + work, }) if (!result.user) throw new Error('Failed to create user and profile') - const promises: Promise[] = filterDefined([ - interests && api('update-options', {table: 'interests', values: interests}), - causes && api('update-options', {table: 'causes', values: causes}), - work && api('update-options', {table: 'work', values: work}), - ]) - await Promise.all(promises) track('complete registration') clearOnboardingFlag() + // Stash the fresh profile data so the next page can use it immediately + safeLocalStorage?.setItem('freshSignup', JSON.stringify(result)) + // Force onIdTokenChanged to re-fire — your AuthProvider listener // will then re-run getUserSafe, find the record, and call onAuthLoad await auth.currentUser?.getIdToken(true) // true = force refresh - // Just to be sure everything is in the db before the server calls in profile props - if (IS_DEPLOYED) await sleep(5000) - router.push(`/${result.user.username}?fromSignup=true`) } catch (e) { console.error(e) toast.error('An error occurred during signup, try again later...') + } finally { + setIsSubmitting(false) } } - // if (user === null && !holdLoading && !isNewRegistration) { - // return - // } - - const showLoading = false - return ( - {showLoading ? ( - + {isSubmitting ? ( + + +
Creating your profile...
+ ) : (