Update options in same API call along with user creation

This commit is contained in:
MartinBraquet
2026-03-05 15:40:20 +01:00
parent 32e8c8570b
commit 512406837d
6 changed files with 139 additions and 119 deletions

View File

@@ -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,
}

View File

@@ -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}
}

View File

@@ -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',

View File

@@ -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
}

View File

@@ -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<UserPageProps>(() => {
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:',

View File

@@ -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<any>[] = 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 <CompassLoadingIndicator />
// }
const showLoading = false
return (
<Col className="items-center">
<Toaster position={'top-center'} containerClassName="!bottom-[70px]" />
{showLoading ? (
<CompassLoadingIndicator />
{isSubmitting ? (
<Col className="flex-1 items-center justify-center py-20">
<CompassLoadingIndicator />
<div className="mt-4 text-gray-500">Creating your profile...</div>
</Col>
) : (
<Col className={'w-full max-w-4xl px-6 py-4'}>
<BackButton className="-ml-2 mb-2 self-start" />