mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-03-24 17:41:27 -04:00
Update options in same API call along with user creation
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:',
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user