Merge branch 'collapse_registration'

This commit is contained in:
MartinBraquet
2026-03-04 15:23:31 +01:00
42 changed files with 1530 additions and 659 deletions

View File

@@ -54,6 +54,7 @@ import {createPrivateUserMessage} from './create-private-user-message'
import {createPrivateUserMessageChannel} from './create-private-user-message-channel'
import {createProfile} from './create-profile'
import {createUser} from './create-user'
import {createUserAndProfile} from './create-user-and-profile'
import {deleteBookmarkedSearch} from './delete-bookmarked-search'
import {deleteCompatibilityAnswer} from './delete-compatibility-answer'
import {deleteMe} from './delete-me'
@@ -91,6 +92,7 @@ import {updateNotifSettings} from './update-notif-setting'
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
import {updateProfile} from './update-profile'
import {updateUserLocale} from './update-user-locale'
import {validateUsernameEndpoint} from './validate-username'
// const corsOptions: CorsOptions = {
// origin: ['*'], // Only allow requests from this domain
@@ -332,6 +334,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
'create-private-user-message-channel': createPrivateUserMessageChannel,
'create-profile': createProfile,
'create-user': createUser,
'create-user-and-profile': createUserAndProfile,
'create-vote': createVote,
'delete-bookmarked-search': deleteBookmarkedSearch,
'delete-compatibility-answer': deleteCompatibilityAnswer,
@@ -383,6 +386,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
'user/by-id/:id/block': blockUser,
'user/by-id/:id/unblock': unblockUser,
vote: vote,
'validate-username': validateUsernameEndpoint,
// 'user/:username': getUser,
// 'user/:username/lite': getDisplayUser,
// 'user/by-id/:id/lite': getDisplayUser,

View File

@@ -29,7 +29,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
if (!user) throw new APIError(401, 'Your account was not found')
if (user.createdTime > Date.now() - HOUR_MS) {
// If they just signed up, set their avatar to be their pinned photo
updateUser(pg, auth.uid, {avatarUrl: body.pinned_url})
updateUser(pg, auth.uid, {avatarUrl: body.pinned_url || undefined})
}
debug('body', body)

View File

@@ -0,0 +1,186 @@
import {setLastOnlineTimeUser} from 'api/set-last-online-time'
import {defaultLocale} from 'common/constants'
import {sendDiscordMessage} from 'common/discord/core'
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
import {debug} from 'common/logger'
import {trimStrings} from 'common/parsing'
import {convertPrivateUser, convertUser} from 'common/supabase/users'
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'
import {getBucket} from 'shared/firebase-utils'
import {generateAvatarUrl} from 'shared/helpers/generate-and-update-avatar-urls'
import {removePinnedUrlFromPhotoUrls} from 'shared/profiles/parse-photos'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {getUserByUsername, log} from 'shared/utils'
import {APIError, APIHandler} from './helpers/endpoint'
import {validateUsername} from './validate-username'
export const createUserAndProfile: APIHandler<'create-user-and-profile'> = async (
props,
auth,
req,
) => {
trimStrings(props)
const {deviceToken, locale = defaultLocale, username, name, link, profile} = props
await removePinnedUrlFromPhotoUrls(profile)
const host = req.get('referer')
log(`Create user and profile from: ${host}`)
const ip = getIp(req)
const pg = createSupabaseDirectClient()
const cleanName = cleanDisplayName(name || 'User')
const fbUser = await admin.auth().getUser(auth.uid)
const email = fbUser.email
const bucket = getBucket()
const avatarUrl = await generateAvatarUrl(auth.uid, cleanName, bucket)
let finalUsername = username
const validation = await validateUsername(username)
if (!validation.valid) {
if (validation.suggestedUsername) {
finalUsername = validation.suggestedUsername
} else {
throw new APIError(400, validation.message || 'Invalid username')
}
}
const {user, privateUser} = 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})
}
const sameNameUser = await getUserByUsername(finalUsername, tx)
if (sameNameUser) {
throw new APIError(403, 'Username already taken', {username: finalUsername})
}
const userData = removeUndefinedProps({
avatarUrl,
isBannedFromPosting: Boolean(
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
(ip && bannedIpAddresses.includes(ip)),
),
link: link,
})
const privateUserData: PrivateUser = {
id: auth.uid,
email,
locale,
initialIpAddress: ip,
initialDeviceToken: deviceToken,
notificationPreferences: getDefaultNotificationPreferences(),
blockedUserIds: [],
blockedByUserIds: [],
}
const newUserRow = await insert(tx, 'users', {
id: auth.uid,
name: cleanName,
username: finalUsername,
data: userData,
})
const newPrivateUserRow = await insert(tx, 'private_users', {
id: privateUserData.id,
data: privateUserData,
})
const profileData = removeUndefinedProps(profile)
await insert(tx, 'profiles', {
user_id: auth.uid,
...profileData,
})
return {
user: convertUser(newUserRow),
privateUser: convertPrivateUser(newPrivateUserRow),
}
})
log('created user and profile', {username: user.username, firebaseId: auth.uid})
const continuation = async () => {
try {
await track(auth.uid, 'create profile', {username: user.username})
} catch (e) {
console.error('Failed to track create profile', e)
}
try {
await sendWelcomeEmail(user, privateUser)
} catch (e) {
console.error('Failed to sendWelcomeEmail', e)
}
try {
await setLastOnlineTimeUser(auth.uid)
} catch (e) {
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) {
console.error('Failed to send discord new profile', e)
}
try {
const nProfiles = await pg.one<number>(`SELECT count(*) FROM profiles`, [], (r) =>
Number(r.count),
)
const isMilestone = (n: number) => {
return (
[15, 20, 30, 40].includes(n) || // early milestones
n % 50 === 0
)
}
debug(nProfiles, isMilestone(nProfiles))
if (isMilestone(nProfiles)) {
await sendDiscordMessage(`We just reached **${nProfiles}** total profiles! 🎉`, 'general')
}
} catch (e) {
console.error('Failed to send discord user milestone', e)
}
}
return {
result: {
user,
privateUser,
},
continue: continuation,
}
}
const bannedDeviceTokens = [
'fa807d664415',
'dcf208a11839',
'bbf18707c15d',
'4c2d15a6cc0c',
'0da6b4ea79d3',
]
const bannedIpAddresses: string[] = [
'24.176.214.250',
'2607:fb90:bd95:dbcd:ac39:6c97:4e35:3fed',
'2607:fb91:389:ddd0:ac39:8397:4e57:f060',
'2607:fb90:ed9a:4c8f:ac39:cf57:4edd:4027',
'2607:fb90:bd36:517a:ac39:6c91:812c:6328',
]

View File

@@ -49,7 +49,7 @@ export const createUser: APIHandler<'create-user'> = async (props, auth, req) =>
(r) => r.count,
)
const usernameExists = dupes > 0
const isReservedName = RESERVED_PATHS.includes(username)
const isReservedName = RESERVED_PATHS.has(username)
if (usernameExists || isReservedName) username += randomString(4)
const {user, privateUser} = await pg.tx(async (tx) => {

View File

@@ -238,12 +238,16 @@ function checkRateLimit(name: string, req: Request, res: Response, auth?: Authed
}
export const typedEndpoint = <N extends APIPath>(name: N, handler: APIHandler<N>) => {
const apiSchema = API[name] as APISchema<N> & {
deprecation?: {deprecated: boolean; migrationPath?: string; sunsetDate?: string}
}
const {
props: propSchema,
authed: authRequired,
rateLimited = false,
method,
} = API[name] as APISchema<N>
deprecation,
} = apiSchema
return async (req: Request, res: Response, next: NextFunction) => {
let authUser: AuthedUser | undefined = undefined
@@ -262,6 +266,10 @@ export const typedEndpoint = <N extends APIPath>(name: N, handler: APIHandler<N>
}
}
if (deprecation?.deprecated) {
log('Deprecated endpoint called:', name, req)
}
const props = {
...(method === 'GET' ? req.query : req.body),
...req.params,
@@ -281,6 +289,17 @@ export const typedEndpoint = <N extends APIPath>(name: N, handler: APIHandler<N>
const result = hasContinue ? resultOptionalContinue.result : resultOptionalContinue
if (!res.headersSent) {
// Add deprecation headers for deprecated endpoints
if (deprecation?.deprecated) {
res.setHeader('Deprecation', 'true')
if (deprecation.sunsetDate) {
res.setHeader('Sunset', deprecation.sunsetDate)
}
if (deprecation.migrationPath) {
res.setHeader('Link', `<${deprecation.migrationPath}>; rel="migration"`)
}
}
// Convert bigint to number, b/c JSON doesn't support bigint.
const convertedResult = deepConvertBigIntToNumber(result)
// console.debug('API result', convertedResult)

View File

@@ -1,5 +1,6 @@
import {toUserAPIResponse} from 'common/api/user-types'
import {RESERVED_PATHS} from 'common/envs/constants'
import {debug} from 'common/logger'
import {strip} from 'common/socials'
import {cleanDisplayName, cleanUsername} from 'common/util/clean-username'
import {removeUndefinedProps} from 'common/util/object'
@@ -24,15 +25,18 @@ export const updateMe: APIHandler<'me/update'> = async (props, auth) => {
if (update.username) {
const cleanedUsername = cleanUsername(update.username)
if (!cleanedUsername) throw new APIError(400, 'Invalid username')
const reservedName = RESERVED_PATHS.includes(cleanedUsername)
const reservedName = RESERVED_PATHS.has(cleanedUsername)
if (reservedName) throw new APIError(403, 'This username is reserved')
const otherUserExists = await getUserByUsername(cleanedUsername)
if (otherUserExists) throw new APIError(403, 'Username already taken')
if (otherUserExists && otherUserExists.id !== auth.uid)
throw new APIError(403, 'Username already taken')
update.username = cleanedUsername
}
const pg = createSupabaseDirectClient()
debug({update})
const {name, username, avatarUrl, link = {}, ...rest} = update
await updateUser(pg, auth.uid, removeUndefinedProps(rest))

View File

@@ -0,0 +1,47 @@
import {RESERVED_PATHS} from 'common/envs/constants'
import {debug} from 'common/logger'
import {cleanUsername} from 'common/util/clean-username'
import {randomString} from 'common/util/random'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIHandler} from './helpers/endpoint'
export type ValidationResult = {
valid: boolean
message?: string
suggestedUsername?: string
}
export async function validateUsername(username: string): Promise<ValidationResult> {
const pg = createSupabaseDirectClient()
const cleanUsernameStr = cleanUsername(username)
if (!cleanUsernameStr) {
return {valid: false, message: 'Username cannot be empty'}
}
if (RESERVED_PATHS.has(cleanUsernameStr)) {
const suggested = cleanUsernameStr + randomString(4)
return {valid: false, message: 'This username is reserved', suggestedUsername: suggested}
}
const dupes = await pg.one<number>(
`select count(*) from users where username ilike $1`,
[cleanUsernameStr],
(r) => r.count,
)
if (dupes > 0) {
const suggested = cleanUsernameStr + randomString(4)
return {valid: false, message: 'This username is already taken', suggestedUsername: suggested}
}
return {valid: true, suggestedUsername: cleanUsernameStr}
}
export const validateUsernameEndpoint: APIHandler<'validate-username'> = async (props) => {
const {username} = props
const result = await validateUsername(username)
debug(result)
return result
}

View File

@@ -0,0 +1,749 @@
jest.mock('shared/supabase/init')
jest.mock('shared/supabase/utils')
jest.mock('common/supabase/users')
jest.mock('email/functions/helpers')
jest.mock('api/set-last-online-time')
jest.mock('firebase-admin', () => ({
auth: jest.fn(() => ({
getUser: jest.fn(),
})),
}))
jest.mock('shared/utils')
jest.mock('shared/analytics')
jest.mock('shared/firebase-utils')
jest.mock('shared/helpers/generate-and-update-avatar-urls')
jest.mock('common/util/object')
jest.mock('common/user-notification-preferences')
jest.mock('common/util/clean-username')
jest.mock('shared/monitoring/log')
jest.mock('common/hosting/constants')
jest.mock('shared/profiles/parse-photos')
jest.mock('common/discord/core')
jest.mock('common/util/try-catch')
jest.mock('common/util/time')
jest.mock('api/validate-username')
jest.mock('common/logger')
import {createUserAndProfile} from 'api/create-user-and-profile'
import {AuthedUser} from 'api/helpers/endpoint'
import * as apiSetLastTimeOnline from 'api/set-last-online-time'
import * as validateUsernameModule from 'api/validate-username'
import {sendDiscordMessage} from 'common/discord/core'
import * as hostingConstants from 'common/hosting/constants'
import * as supabaseUsers from 'common/supabase/users'
import * as usernameUtils from 'common/util/clean-username'
import * as objectUtils from 'common/util/object'
import * as timeUtils from 'common/util/time'
import * as emailHelpers from 'email/functions/helpers'
import * as firebaseAdmin from 'firebase-admin'
import * as sharedAnalytics from 'shared/analytics'
import * as firebaseUtils from 'shared/firebase-utils'
import * as avatarHelpers from 'shared/helpers/generate-and-update-avatar-urls'
import * as parsePhotos from 'shared/profiles/parse-photos'
import * as supabaseInit from 'shared/supabase/init'
import * as supabaseUtils from 'shared/supabase/utils'
import * as sharedUtils from 'shared/utils'
describe('createUserAndProfile', () => {
const originalIsLocal = (hostingConstants as any).IS_LOCAL
let mockPg = {} as any
beforeEach(() => {
jest.resetAllMocks()
mockPg = {
oneOrNone: jest.fn(),
one: jest.fn(),
tx: jest.fn(async (cb: any) => {
const mockTx = {
oneOrNone: jest.fn(),
one: jest.fn(),
}
return cb(mockTx)
}),
}
;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: jest.fn().mockResolvedValue({email: 'test@test.com'}),
})
;(sharedUtils.getUserByUsername as jest.Mock).mockResolvedValue(false)
})
afterEach(() => {
jest.restoreAllMocks()
Object.defineProperty(hostingConstants, 'IS_LOCAL', {
value: originalIsLocal,
writable: true,
})
})
describe('when given valid input', () => {
it('should successfully create a user and profile', async () => {
Object.defineProperty(hostingConstants, 'IS_LOCAL', {
value: false,
writable: true,
})
const mockProps = {
deviceToken: 'mockDeviceToken',
username: 'mockUsername',
name: 'mockName',
link: {mockLink: 'mockLinkData'},
profile: {
city: 'mockCity',
gender: 'mockGender',
looking_for_matches: true,
photo_urls: ['mockPhotoUrl1'],
pinned_url: 'mockPinnedUrl',
pref_gender: ['mockPrefGender'],
pref_relation_styles: ['mockPrefRelationStyles'],
visibility: 'public' as 'public' | 'member',
wants_kids_strength: 2,
},
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockIp = 'mockIP'
const mockBucket = {} as any
const mockAvatarUrl = 'mockGeneratedAvatarUrl'
const mockNewUserRow = {
created_time: 'mockCreatedTime',
data: {mockNewUserJson: 'mockNewUserJsonData'},
id: 'mockNewUserId',
name: 'mockName',
name_username_vector: 'mockNameUsernameVector',
username: 'mockUsername',
}
const mockPrivateUserRow = {
data: {mockPrivateUserJson: 'mockPrivateUserJsonData'},
id: 'mockPrivateUserId',
}
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue('mockName')
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(avatarHelpers.generateAvatarUrl as jest.Mock).mockResolvedValue(mockAvatarUrl)
;(validateUsernameModule.validateUsername as jest.Mock).mockResolvedValue({valid: true})
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: jest.fn().mockResolvedValue({email: 'test@test.com'}),
})
;(parsePhotos.removePinnedUrlFromPhotoUrls as jest.Mock).mockReturnValue({
...mockProps.profile,
})
;(mockPg.tx as jest.Mock).mockImplementation(async (cb: any) => {
const mockTx = {
oneOrNone: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null),
one: jest.fn(),
}
return cb(mockTx)
})
;(supabaseUtils.insert as jest.Mock)
.mockResolvedValueOnce(mockNewUserRow)
.mockResolvedValueOnce(mockPrivateUserRow)
;(supabaseUsers.convertUser as jest.Mock).mockReturnValue(mockNewUserRow)
;(supabaseUsers.convertPrivateUser as jest.Mock).mockReturnValue(mockPrivateUserRow)
const results: any = await createUserAndProfile(mockProps, mockAuth, mockReq)
expect(results.result.user).toEqual(mockNewUserRow)
expect(results.result.privateUser).toEqual(mockPrivateUserRow)
expect(usernameUtils.cleanDisplayName).toBeCalledTimes(1)
expect(usernameUtils.cleanDisplayName).toHaveBeenCalledWith(mockProps.name)
expect(firebaseUtils.getBucket).toBeCalledTimes(1)
expect(avatarHelpers.generateAvatarUrl).toBeCalledTimes(1)
expect(validateUsernameModule.validateUsername).toBeCalledTimes(1)
expect(validateUsernameModule.validateUsername).toHaveBeenCalledWith(mockProps.username)
expect(parsePhotos.removePinnedUrlFromPhotoUrls).toBeCalledTimes(1)
expect(parsePhotos.removePinnedUrlFromPhotoUrls).toHaveBeenCalledWith(mockProps.profile)
expect(mockPg.tx).toBeCalledTimes(1)
expect(supabaseUtils.insert).toBeCalledTimes(3)
expect(supabaseUtils.insert).toHaveBeenNthCalledWith(
1,
expect.any(Object),
'users',
expect.objectContaining({
id: mockAuth.uid,
name: 'mockName',
username: mockProps.username,
}),
)
expect(supabaseUtils.insert).toHaveBeenNthCalledWith(
2,
expect.any(Object),
'private_users',
expect.objectContaining({
id: mockAuth.uid,
}),
)
expect(supabaseUtils.insert).toHaveBeenNthCalledWith(
3,
expect.any(Object),
'profiles',
expect.objectContaining({
user_id: mockAuth.uid,
}),
)
;(sharedAnalytics.track as jest.Mock).mockResolvedValue(null)
;(emailHelpers.sendWelcomeEmail as jest.Mock).mockResolvedValue(null)
;(apiSetLastTimeOnline.setLastOnlineTimeUser as jest.Mock).mockResolvedValue(null)
;(timeUtils.sleep as jest.Mock).mockResolvedValue(null)
;(sendDiscordMessage as jest.Mock).mockResolvedValue(null)
;(mockPg.one as jest.Mock).mockResolvedValue({count: 10})
await results.continue()
expect(sharedAnalytics.track).toBeCalledTimes(1)
expect(sharedAnalytics.track).toBeCalledWith(mockAuth.uid, 'create profile', {
username: mockNewUserRow.username,
})
expect(emailHelpers.sendWelcomeEmail).toBeCalledTimes(1)
expect(emailHelpers.sendWelcomeEmail).toBeCalledWith(mockNewUserRow, mockPrivateUserRow)
expect(apiSetLastTimeOnline.setLastOnlineTimeUser).toBeCalledTimes(1)
expect(apiSetLastTimeOnline.setLastOnlineTimeUser).toBeCalledWith(mockAuth.uid)
expect(timeUtils.sleep).toBeCalledTimes(1)
expect(timeUtils.sleep).toBeCalledWith(60000)
expect(sendDiscordMessage).toBeCalledTimes(1)
})
it('should use suggested username when provided username is invalid', async () => {
const mockProps = {
deviceToken: 'mockDeviceToken',
username: 'invalid!username',
name: 'mockName',
link: {},
profile: {
city: 'mockCity',
gender: 'mockGender',
visibility: 'public' as 'public' | 'member',
},
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockIp = 'mockIP'
const mockBucket = {} as any
const mockAvatarUrl = 'mockGeneratedAvatarUrl'
const mockNewUserRow = {
created_time: 'mockCreatedTime',
data: {},
id: 'mockNewUserId',
name: 'mockName',
name_username_vector: 'mockNameUsernameVector',
username: 'suggestedUsername',
}
const mockPrivateUserRow = {
data: {},
id: 'mockPrivateUserId',
}
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue('mockName')
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(avatarHelpers.generateAvatarUrl as jest.Mock).mockResolvedValue(mockAvatarUrl)
;(validateUsernameModule.validateUsername as jest.Mock).mockResolvedValue({
valid: false,
suggestedUsername: 'suggestedUsername',
})
;(parsePhotos.removePinnedUrlFromPhotoUrls as jest.Mock).mockReturnValue(mockProps.profile)
;(mockPg.tx as jest.Mock).mockImplementation(async (cb: any) => {
const mockTx = {
oneOrNone: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null),
one: jest.fn(),
}
return cb(mockTx)
})
;(supabaseUtils.insert as jest.Mock)
.mockResolvedValueOnce(mockNewUserRow)
.mockResolvedValueOnce(mockPrivateUserRow)
;(supabaseUsers.convertUser as jest.Mock).mockReturnValue(mockNewUserRow)
;(supabaseUsers.convertPrivateUser as jest.Mock).mockReturnValue(mockPrivateUserRow)
const results: any = await createUserAndProfile(mockProps, mockAuth, mockReq)
expect(results.result.user).toEqual(mockNewUserRow)
expect(supabaseUtils.insert).toHaveBeenNthCalledWith(
1,
expect.any(Object),
'users',
expect.objectContaining({
username: 'suggestedUsername',
}),
)
})
it('should ban user from posting if device token is banned', async () => {
const mockProps = {
deviceToken: 'fa807d664415',
username: 'mockUsername',
name: 'mockName',
link: {},
profile: {
city: 'mockCity',
gender: 'mockGender',
visibility: 'public' as 'public' | 'member',
},
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockIp = 'mockIP'
const mockBucket = {} as any
const mockAvatarUrl = 'mockGeneratedAvatarUrl'
const mockNewUserRow = {
created_time: 'mockCreatedTime',
data: {isBannedFromPosting: true},
id: 'mockNewUserId',
name: 'mockName',
name_username_vector: 'mockNameUsernameVector',
username: 'mockUsername',
}
const mockPrivateUserRow = {
data: {},
id: 'mockPrivateUserId',
}
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue('mockName')
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(avatarHelpers.generateAvatarUrl as jest.Mock).mockResolvedValue(mockAvatarUrl)
;(validateUsernameModule.validateUsername as jest.Mock).mockResolvedValue({valid: true})
;(parsePhotos.removePinnedUrlFromPhotoUrls as jest.Mock).mockReturnValue(mockProps.profile)
;(mockPg.tx as jest.Mock).mockImplementation(async (cb: any) => {
const mockTx = {
oneOrNone: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null),
one: jest.fn(),
}
return cb(mockTx)
})
;(supabaseUtils.insert as jest.Mock)
.mockResolvedValueOnce(mockNewUserRow)
.mockResolvedValueOnce(mockPrivateUserRow)
;(supabaseUsers.convertUser as jest.Mock).mockReturnValue(mockNewUserRow)
;(supabaseUsers.convertPrivateUser as jest.Mock).mockReturnValue(mockPrivateUserRow)
await createUserAndProfile(mockProps, mockAuth, mockReq)
expect(objectUtils.removeUndefinedProps).toHaveBeenCalledWith(
expect.objectContaining({
isBannedFromPosting: true,
}),
)
})
})
describe('when an error occurs', () => {
it('should throw if the user already exists', async () => {
const mockProps = {
deviceToken: 'mockDeviceToken',
username: 'mockUsername',
name: 'mockName',
link: {},
profile: {
city: 'mockCity',
gender: 'mockGender',
visibility: 'public' as 'public' | 'member',
},
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockIp = 'mockIP'
const mockBucket = {} as any
const mockAvatarUrl = 'mockGeneratedAvatarUrl'
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue('mockName')
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(avatarHelpers.generateAvatarUrl as jest.Mock).mockResolvedValue(mockAvatarUrl)
;(validateUsernameModule.validateUsername as jest.Mock).mockResolvedValue({valid: true})
;(parsePhotos.removePinnedUrlFromPhotoUrls as jest.Mock).mockReturnValue(mockProps.profile)
;(mockPg.tx as jest.Mock).mockImplementation(async (cb: any) => {
const mockTx = {
oneOrNone: jest.fn().mockResolvedValueOnce({id: 'existingUserId'}),
one: jest.fn(),
}
return cb(mockTx)
})
await expect(createUserAndProfile(mockProps, mockAuth, mockReq)).rejects.toThrowError(
'User already exists',
)
})
it('should throw if the username is already taken', async () => {
const mockProps = {
deviceToken: 'mockDeviceToken',
username: 'takenUsername',
name: 'mockName',
link: {},
profile: {
city: 'mockCity',
gender: 'mockGender',
visibility: 'public' as 'public' | 'member',
},
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockIp = 'mockIP'
const mockBucket = {} as any
const mockAvatarUrl = 'mockGeneratedAvatarUrl'
const mockExistingUser = {id: 'existingUserId', username: 'takenUsername'}
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue('mockName')
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(avatarHelpers.generateAvatarUrl as jest.Mock).mockResolvedValue(mockAvatarUrl)
;(validateUsernameModule.validateUsername as jest.Mock).mockResolvedValue({valid: true})
;(parsePhotos.removePinnedUrlFromPhotoUrls as jest.Mock).mockReturnValue(mockProps.profile)
;(sharedUtils.getUserByUsername as jest.Mock).mockResolvedValue(mockExistingUser)
;(mockPg.tx as jest.Mock).mockImplementation(async (cb: any) => {
const mockTx = {
oneOrNone: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(mockExistingUser),
one: jest.fn(),
}
return cb(mockTx)
})
await expect(createUserAndProfile(mockProps, mockAuth, mockReq)).rejects.toThrowError(
'Username already taken',
)
})
it('should throw if username is invalid and no suggestion is provided', async () => {
const mockProps = {
deviceToken: 'mockDeviceToken',
username: 'invalid!username',
name: 'mockName',
link: {},
profile: {
city: 'mockCity',
gender: 'mockGender',
visibility: 'public' as 'public' | 'member',
},
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockIp = 'mockIP'
const mockBucket = {} as any
const mockAvatarUrl = 'mockGeneratedAvatarUrl'
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue('mockName')
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(avatarHelpers.generateAvatarUrl as jest.Mock).mockResolvedValue(mockAvatarUrl)
;(validateUsernameModule.validateUsername as jest.Mock).mockResolvedValue({
valid: false,
message: 'Invalid username',
})
;(parsePhotos.removePinnedUrlFromPhotoUrls as jest.Mock).mockReturnValue(mockProps.profile)
await expect(createUserAndProfile(mockProps, mockAuth, mockReq)).rejects.toThrowError(
'Invalid username',
)
})
it('should continue without throwing if tracking fails', async () => {
Object.defineProperty(hostingConstants, 'IS_LOCAL', {
value: false,
writable: true,
})
const mockProps = {
deviceToken: 'mockDeviceToken',
username: 'mockUsername',
name: 'mockName',
link: {},
profile: {
city: 'mockCity',
gender: 'mockGender',
visibility: 'public' as 'public' | 'member',
},
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockIp = 'mockIP'
const mockBucket = {} as any
const mockAvatarUrl = 'mockGeneratedAvatarUrl'
const mockNewUserRow = {
created_time: 'mockCreatedTime',
data: {},
id: 'mockNewUserId',
name: 'mockName',
name_username_vector: 'mockNameUsernameVector',
username: 'mockUsername',
}
const mockPrivateUserRow = {
data: {},
id: 'mockPrivateUserId',
}
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue('mockName')
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(avatarHelpers.generateAvatarUrl as jest.Mock).mockResolvedValue(mockAvatarUrl)
;(validateUsernameModule.validateUsername as jest.Mock).mockResolvedValue({valid: true})
;(parsePhotos.removePinnedUrlFromPhotoUrls as jest.Mock).mockReturnValue(mockProps.profile)
;(mockPg.tx as jest.Mock).mockImplementation(async (cb: any) => {
const mockTx = {
oneOrNone: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null),
one: jest.fn(),
}
return cb(mockTx)
})
;(supabaseUtils.insert as jest.Mock)
.mockResolvedValueOnce(mockNewUserRow)
.mockResolvedValueOnce(mockPrivateUserRow)
;(supabaseUsers.convertUser as jest.Mock).mockReturnValue(mockNewUserRow)
;(supabaseUsers.convertPrivateUser as jest.Mock).mockReturnValue(mockPrivateUserRow)
const results: any = await createUserAndProfile(mockProps, mockAuth, mockReq)
;(sharedAnalytics.track as jest.Mock).mockRejectedValue(new Error('Tracking failed'))
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
await results.continue()
expect(errorSpy).toHaveBeenCalledWith('Failed to track create profile', expect.any(Error))
})
it('should continue without throwing if welcome email fails', async () => {
Object.defineProperty(hostingConstants, 'IS_LOCAL', {
value: false,
writable: true,
})
const mockProps = {
deviceToken: 'mockDeviceToken',
username: 'mockUsername',
name: 'mockName',
link: {},
profile: {
city: 'mockCity',
gender: 'mockGender',
visibility: 'public' as 'public' | 'member',
},
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockIp = 'mockIP'
const mockBucket = {} as any
const mockAvatarUrl = 'mockGeneratedAvatarUrl'
const mockNewUserRow = {
created_time: 'mockCreatedTime',
data: {},
id: 'mockNewUserId',
name: 'mockName',
name_username_vector: 'mockNameUsernameVector',
username: 'mockUsername',
}
const mockPrivateUserRow = {
data: {},
id: 'mockPrivateUserId',
}
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue('mockName')
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(avatarHelpers.generateAvatarUrl as jest.Mock).mockResolvedValue(mockAvatarUrl)
;(validateUsernameModule.validateUsername as jest.Mock).mockResolvedValue({valid: true})
;(parsePhotos.removePinnedUrlFromPhotoUrls as jest.Mock).mockReturnValue(mockProps.profile)
;(mockPg.tx as jest.Mock).mockImplementation(async (cb: any) => {
const mockTx = {
oneOrNone: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null),
one: jest.fn(),
}
return cb(mockTx)
})
;(supabaseUtils.insert as jest.Mock)
.mockResolvedValueOnce(mockNewUserRow)
.mockResolvedValueOnce(mockPrivateUserRow)
;(supabaseUsers.convertUser as jest.Mock).mockReturnValue(mockNewUserRow)
;(supabaseUsers.convertPrivateUser as jest.Mock).mockReturnValue(mockPrivateUserRow)
const results: any = await createUserAndProfile(mockProps, mockAuth, mockReq)
;(sharedAnalytics.track as jest.Mock).mockResolvedValue(null)
;(emailHelpers.sendWelcomeEmail as jest.Mock).mockRejectedValue(new Error('Email failed'))
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
await results.continue()
expect(errorSpy).toHaveBeenCalledWith('Failed to sendWelcomeEmail', expect.any(Error))
})
it('should continue without throwing if set last online time fails', async () => {
const mockProps = {
deviceToken: 'mockDeviceToken',
username: 'mockUsername',
name: 'mockName',
link: {},
profile: {
city: 'mockCity',
gender: 'mockGender',
visibility: 'public' as 'public' | 'member',
},
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockIp = 'mockIP'
const mockBucket = {} as any
const mockAvatarUrl = 'mockGeneratedAvatarUrl'
const mockNewUserRow = {
created_time: 'mockCreatedTime',
data: {},
id: 'mockNewUserId',
name: 'mockName',
name_username_vector: 'mockNameUsernameVector',
username: 'mockUsername',
}
const mockPrivateUserRow = {
data: {},
id: 'mockPrivateUserId',
}
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue('mockName')
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(avatarHelpers.generateAvatarUrl as jest.Mock).mockResolvedValue(mockAvatarUrl)
;(validateUsernameModule.validateUsername as jest.Mock).mockResolvedValue({valid: true})
;(parsePhotos.removePinnedUrlFromPhotoUrls as jest.Mock).mockReturnValue(mockProps.profile)
;(mockPg.tx as jest.Mock).mockImplementation(async (cb: any) => {
const mockTx = {
oneOrNone: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null),
one: jest.fn(),
}
return cb(mockTx)
})
;(supabaseUtils.insert as jest.Mock)
.mockResolvedValueOnce(mockNewUserRow)
.mockResolvedValueOnce(mockPrivateUserRow)
;(supabaseUsers.convertUser as jest.Mock).mockReturnValue(mockNewUserRow)
;(supabaseUsers.convertPrivateUser as jest.Mock).mockReturnValue(mockPrivateUserRow)
const results: any = await createUserAndProfile(mockProps, mockAuth, mockReq)
;(sharedAnalytics.track as jest.Mock).mockResolvedValue(null)
;(emailHelpers.sendWelcomeEmail as jest.Mock).mockResolvedValue(null)
;(apiSetLastTimeOnline.setLastOnlineTimeUser as jest.Mock).mockRejectedValue(
new Error('Failed to set last online time'),
)
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
await results.continue()
expect(errorSpy).toHaveBeenCalledWith('Failed to set last online time', expect.any(Error))
})
it('should send milestone message when profile count reaches milestone', async () => {
Object.defineProperty(hostingConstants, 'IS_LOCAL', {
value: false,
writable: true,
})
const mockProps = {
deviceToken: 'mockDeviceToken',
username: 'mockUsername',
name: 'mockName',
link: {},
profile: {
city: 'mockCity',
gender: 'mockGender',
visibility: 'public' as 'public' | 'member',
},
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockIp = 'mockIP'
const mockBucket = {} as any
const mockAvatarUrl = 'mockGeneratedAvatarUrl'
const mockNewUserRow = {
created_time: 'mockCreatedTime',
data: {},
id: 'mockNewUserId',
name: 'mockName',
name_username_vector: 'mockNameUsernameVector',
username: 'mockUsername',
}
const mockPrivateUserRow = {
data: {},
id: 'mockPrivateUserId',
}
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue('mockName')
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(avatarHelpers.generateAvatarUrl as jest.Mock).mockResolvedValue(mockAvatarUrl)
;(validateUsernameModule.validateUsername as jest.Mock).mockResolvedValue({valid: true})
;(parsePhotos.removePinnedUrlFromPhotoUrls as jest.Mock).mockReturnValue(mockProps.profile)
;(mockPg.tx as jest.Mock).mockImplementation(async (cb: any) => {
const mockTx = {
oneOrNone: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(null),
one: jest.fn(),
}
return cb(mockTx)
})
;(supabaseUtils.insert as jest.Mock)
.mockResolvedValueOnce(mockNewUserRow)
.mockResolvedValueOnce(mockPrivateUserRow)
;(supabaseUsers.convertUser as jest.Mock).mockReturnValue(mockNewUserRow)
;(supabaseUsers.convertPrivateUser as jest.Mock).mockReturnValue(mockPrivateUserRow)
const results: any = await createUserAndProfile(mockProps, mockAuth, mockReq)
;(sharedAnalytics.track as jest.Mock).mockResolvedValue(null)
;(emailHelpers.sendWelcomeEmail as jest.Mock).mockResolvedValue(null)
;(apiSetLastTimeOnline.setLastOnlineTimeUser as jest.Mock).mockResolvedValue(null)
;(timeUtils.sleep as jest.Mock).mockResolvedValue(null)
;(sendDiscordMessage as jest.Mock).mockResolvedValue(null)
const mockOneFn = jest.fn().mockResolvedValue({count: 50})
mockPg.one = mockOneFn
await results.continue()
expect(mockOneFn).toHaveBeenCalled()
expect(sendDiscordMessage).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -122,17 +122,14 @@ describe('updateMe', () => {
it('should throw if the username is reserved', async () => {
const mockProps = {
name: 'mockName',
username: 'mockUsername',
username: 'home',
avatarUrl: 'mockAvatarUrl',
link: {mockLink: 'mockLinkValue'},
} as any
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
const arraySpy = jest.spyOn(Array.prototype, 'includes')
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(true)
arraySpy.mockReturnValue(true)
expect(updateMe(mockProps, mockAuth, mockReq)).rejects.toThrow('This username is reserved')
})

View File

@@ -1,4 +1,4 @@
import {DOMAIN} from 'common/envs/constants'
import {DOMAIN, FIREBASE_STORAGE_URL} from 'common/envs/constants'
import {debug} from 'common/logger'
import {Bucket} from 'shared/firebase-utils'
@@ -41,5 +41,5 @@ async function upload(userId: string, buffer: Buffer, bucket: Bucket) {
public: true,
metadata: {contentType: 'image/png'},
})
return `https://storage.googleapis.com/${bucket.cloudStorageURI.hostname}/${filename}`
return `${FIREBASE_STORAGE_URL}/${bucket.cloudStorageURI.hostname}/${filename}`
}

View File

@@ -8,6 +8,7 @@ import {
ServerMessage,
} from 'common/api/websockets'
import {IS_LOCAL} from 'common/hosting/constants'
import {debug} from 'common/logger'
import {isError} from 'lodash'
import {log, metrics} from 'shared/utils'
import {RawData, Server as WebSocketServer, WebSocket} from 'ws'
@@ -139,12 +140,12 @@ export function listen(server: HttpServer, path: string) {
deadConnectionCleaner = setInterval(() => {
for (const ws of wss.clients as Set<HeartbeatWebSocket>) {
if (ws.isAlive === false) {
log.debug('Terminating dead connection')
debug('Terminating dead connection')
ws.terminate()
continue
}
ws.isAlive = false
// log.debug('Sending ping to client');
// debug('Sending ping to client');
ws.ping()
}
}, 25000)
@@ -154,11 +155,11 @@ export function listen(server: HttpServer, path: string) {
})
wss.on('connection', (ws: HeartbeatWebSocket) => {
ws.isAlive = true
// log.debug('Received pong from client');
// debug('Received pong from client');
ws.on('pong', () => (ws.isAlive = true))
metrics.inc('ws/connections_established')
metrics.set('ws/open_connections', wss.clients.size)
log.debug('WS client connected.')
debug('WS client connected.')
SWITCHBOARD.connect(ws)
ws.on('message', (data) => {
const result = processMessage(ws, data)
@@ -168,7 +169,7 @@ export function listen(server: HttpServer, path: string) {
ws.on('close', (code, reason) => {
metrics.inc('ws/connections_terminated')
metrics.set('ws/open_connections', wss.clients.size)
log.debug(`WS client disconnected.`, {code, reason: reason.toString()})
debug(`WS client disconnected.`, {code, reason: reason.toString()})
SWITCHBOARD.disconnect(ws)
})
ws.on('error', (err) => {

View File

@@ -41,6 +41,12 @@ type APIGenericSchema = {
summary?: string
// Tag for grouping endpoints in documentation
tag?: string
// Deprecation info for endpoints
deprecation?: {
deprecated: boolean
migrationPath?: string
sunsetDate?: string
}
}
let _apiTypeCheck: {[x: string]: APIGenericSchema}
@@ -152,6 +158,11 @@ export const API = (_apiTypeCheck = {
method: 'POST',
authed: true,
rateLimited: true,
deprecation: {
deprecated: true,
migrationPath: '/create-user-and-profile',
sunsetDate: 'Sat, 01 Jun 2025 00:00:00 GMT',
},
returns: {} as {user: User; privateUser: PrivateUser},
props: z
.object({
@@ -160,18 +171,43 @@ export const API = (_apiTypeCheck = {
locale: z.string().optional(),
})
.strict(),
summary: 'Create a new user (admin or onboarding flow)',
summary:
'Create a new user (admin or onboarding flow) - DEPRECATED: use create-user-and-profile instead',
tag: 'Users',
},
'create-profile': {
method: 'POST',
authed: true,
rateLimited: true,
deprecation: {
deprecated: true,
migrationPath: '/create-user-and-profile',
sunsetDate: 'Sat, 01 Jun 2025 00:00:00 GMT',
},
returns: {} as Row<'profiles'>,
props: baseProfilesSchema,
summary: 'Create a new profile for the authenticated user',
summary:
'Create a new profile for the authenticated user - DEPRECATED: use create-user-and-profile instead',
tag: 'Profiles',
},
'create-user-and-profile': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as {user: User; privateUser: PrivateUser},
props: z
.object({
deviceToken: z.string().optional(),
locale: z.string().optional(),
username: z.string().min(1),
name: z.string().min(1),
link: z.record(z.string().nullable()).optional(),
profile: combinedProfileSchema,
})
.strict(),
summary: 'Create a new user and profile in a single transaction',
tag: 'Users',
},
report: {
method: 'POST',
authed: true,
@@ -1036,6 +1072,23 @@ export const API = (_apiTypeCheck = {
summary: 'Update an existing event',
tag: 'Events',
},
'validate-username': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as {
valid: boolean
message?: string | undefined
suggestedUsername?: string | undefined
},
props: z
.object({
username: z.string().min(1),
})
.strict(),
summary: 'Validate if a username is available',
tag: 'Users',
},
} as const)
export type APIPath = keyof typeof API

View File

@@ -42,6 +42,7 @@ export const zBoolean = z
.union([z.boolean(), z.string()])
.transform((val) => val === true || val === 'true')
// TODO: merge the two below when the deprecated /create-profile is deleted
export const baseProfilesSchema = z.object({
age: z.number().min(18).max(100).optional().nullable(),
allow_direct_messaging: zBoolean.optional(),
@@ -56,16 +57,16 @@ export const baseProfilesSchema = z.object({
geodb_city_id: z.string().optional().nullable(),
languages: z.array(z.string()).optional().nullable(),
looking_for_matches: zBoolean.optional(),
photo_urls: z.array(z.string()).nullable(),
pinned_url: z.string(),
photo_urls: z.array(z.string()).optional().nullable(),
pinned_url: z.string().optional().nullable(),
pref_age_max: z.number().min(18).max(100).optional().nullable(),
pref_age_min: z.number().min(18).max(100).optional().nullable(),
pref_gender: genderTypes.nullable(),
pref_relation_styles: z.array(z.string()).nullable(),
pref_gender: genderTypes.optional().nullable(),
pref_relation_styles: z.array(z.string()).optional().nullable(),
referred_by_username: z.string().optional().nullable(),
region_code: z.string().optional().nullable(),
visibility: z.union([z.literal('public'), z.literal('member')]),
wants_kids_strength: z.number().nullable(),
wants_kids_strength: z.number().optional().nullable(),
})
const optionalProfilesSchema = z.object({
@@ -100,7 +101,7 @@ const optionalProfilesSchema = z.object({
occupation_title: z.string().optional().nullable(),
political_beliefs: z.array(z.string()).optional().nullable(),
political_details: z.string().optional().nullable(),
pref_romantic_styles: z.array(z.string()).nullable(),
pref_romantic_styles: z.array(z.string()).optional().nullable(),
raised_in_city: z.string().optional().nullable(),
raised_in_country: z.string().optional().nullable(),
raised_in_geodb_city_id: z.string().optional().nullable(),

View File

@@ -60,6 +60,10 @@ export const BACKEND_DOMAIN = IS_LOCAL ? LOCAL_BACKEND_DOMAIN : ENV_CONFIG.backe
export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId
export const FIREBASE_STORAGE_URL = IS_FIREBASE_EMULATOR
? 'http://localhost:9199'
: `https://firebasestorage.googleapis.com`
export const REDIRECT_URI = `${WEB_URL}/auth/callback`
export const AUTH_COOKIE_NAME = `FBUSER_${PROJECT_ID.toUpperCase().replace(/-/g, '_')}`
@@ -70,7 +74,7 @@ export const VERIFIED_USERNAMES = ['Martin']
export const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
export const RESERVED_PATHS = [
export const RESERVED_PATHS = new Set([
'',
'404',
'_app',
@@ -110,6 +114,7 @@ export const RESERVED_PATHS = [
'help',
'home',
'index',
'json',
'link',
'linkAccount',
'links',
@@ -155,7 +160,7 @@ export const RESERVED_PATHS = [
'users',
'web',
'welcome',
]
])
export function getStorageBucketId() {
return ENV_CONFIG.firebaseConfig.storageBucket

View File

@@ -4,6 +4,7 @@ import {Profile} from 'common/profiles/profile'
import {User} from 'common/user'
import {buildOgUrl} from 'common/util/og'
import {parseJsonContentToText} from 'common/util/parse'
import {capitalize} from 'lodash'
// TODO: handle age, gender undefined better
export type ogProps = {
@@ -23,11 +24,10 @@ export type ogProps = {
type NestedStringArray = (string | NestedStringArray | undefined | null)[]
export const flatten = (arr: NestedStringArray, separator: string = ', '): string =>
export const flatten = (arr: NestedStringArray): string[] =>
arr
.flatMap((item) => (Array.isArray(item) ? [flatten(item, separator)] : [item]))
.flatMap((item) => (Array.isArray(item) ? flatten(item) : [item]))
.filter((item): item is string => item != null && item !== '')
.join(separator)
export function getProfileOgImageUrl(
user: User,
@@ -38,21 +38,20 @@ export function getProfileOgImageUrl(
const headline =
profile?.headline ||
parseJsonContentToText(profile?.bio as JSONContent) ||
flatten(
[
// profile?.interests?.map((id: string) => choicesIdsToLabels?.['interests']?.[id]),
// profile?.causes?.map((id: string) => choicesIdsToLabels?.['causes']?.[id]),
// profile?.work?.map((id: string) => choicesIdsToLabels?.['work']?.[id]),
profile?.occupation_title,
profile?.education_level,
profile?.university,
profile?.mbti,
profile?.religion,
profile?.political_beliefs,
profile?.languages,
],
' • ',
)
flatten([
// profile?.interests?.map((id: string) => choicesIdsToLabels?.['interests']?.[id]),
// profile?.causes?.map((id: string) => choicesIdsToLabels?.['causes']?.[id]),
// profile?.work?.map((id: string) => choicesIdsToLabels?.['work']?.[id]),
profile?.occupation_title,
profile?.education_level,
profile?.university,
profile?.mbti,
profile?.religion,
profile?.political_beliefs,
])
.map(capitalize)
.join(' • ')
const props = {
avatarUrl: profile?.pinned_url ?? '',
username: user.username ?? '',

View File

@@ -1,13 +1,16 @@
import {Socials} from './socials'
import {notification_preferences} from './user-notification-preferences'
export type User = {
export type BaseUser = {
id: string
createdTime: number
name: string
username: string
avatarUrl: string
link: Socials // Social links
}
export type User = BaseUser & {
createdTime: number
avatarUrl: string
isBannedFromPosting?: boolean
userDeleted?: boolean
allow_direct_messaging?: boolean

View File

@@ -47,20 +47,3 @@ export function fallbackIfEmpty<T>(array: T[], fallback: any) {
if (!Array.isArray(array)) return fallback
return array.length > 0 ? array : fallback
}
export function nullifyDictValues(array: Record<any, any>) {
// Nullify all the values of the dict
return Object.entries(array).reduce((acc, [key, _]) => {
return {...acc, [key]: null}
}, {})
}
export function sampleDictByPrefix(array: Record<any, any>, prefix: string) {
// Extract the keys that start with the prefix
return Object.entries(array).reduce((acc, [key, value]) => {
if (key.startsWith(prefix)) {
return {...acc, [key]: value}
}
return acc
}, {})
}

View File

@@ -74,3 +74,19 @@ export const hasSignificantDeepChanges = <T extends object>(
return false
}
export function nullifyDictValues(array: Record<any, any>) {
// Nullify all the values of the dict
return Object.entries(array).reduce((acc, [key, _]) => {
return {...acc, [key]: null}
}, {})
}
export function sampleDictByPrefix(array: Record<any, any>, prefix: string) {
// Extract the keys that start with the prefix
return Object.entries(array).reduce((acc, [key, value]) => {
if (key.startsWith(prefix)) {
return {...acc, [key]: value}
}
return acc
}, {})
}

View File

@@ -19,8 +19,8 @@
"dev:isolated": "./scripts/run_local_isolated.sh",
"emulate": "firebase emulators:start --only auth,storage --project compass-57c3c",
"postinstall": "./scripts/post_install.sh",
"lint": "yarn --cwd=web lint; yarn --cwd=common lint; yarn --cwd=backend/api lint; yarn --cwd=backend/shared lint; yarn --cwd=backend/email lint",
"lint-fix": "yarn --cwd=web lint-fix; yarn --cwd=common lint-fix; yarn --cwd=backend/api lint-fix; yarn --cwd=backend/shared lint-fix; yarn --cwd=backend/email lint-fix",
"lint": "yarn --cwd=web lint && yarn --cwd=common lint && yarn --cwd=backend/api lint && yarn --cwd=backend/shared lint && yarn --cwd=backend/email lint",
"lint-fix": "yarn --cwd=web lint-fix && yarn --cwd=common lint-fix && yarn --cwd=backend/api lint-fix && yarn --cwd=backend/shared lint-fix && yarn --cwd=backend/email lint-fix",
"migrate": "./scripts/migrate.sh",
"playwright": "playwright test",
"playwright:debug": "playwright test --debug",
@@ -44,7 +44,7 @@
"test:e2e:ui": "./scripts/e2e.sh --ui",
"test:update": "yarn workspaces run test --updateSnapshot",
"test:watch": "yarn workspaces run test --watch",
"typecheck": "yarn --cwd=web typecheck; yarn --cwd=backend/api typecheck; yarn --cwd=common typecheck; yarn --cwd=backend/shared typecheck; yarn --cwd=backend/email typecheck"
"typecheck": "yarn --cwd=web typecheck && yarn --cwd=backend/api typecheck && yarn --cwd=common typecheck && yarn --cwd=backend/shared typecheck && yarn --cwd=backend/email typecheck"
},
"resolutions": {
"@tiptap/core": "2.10.4",

View File

@@ -2,7 +2,7 @@ import axios from 'axios'
import {config} from '../web/SPEC_CONFIG'
export async function login(email: string, password: string) {
export async function firebaseLogin(email: string, password: string) {
const login = await axios.post(
`${config.FIREBASE_URL.BASE}${config.FIREBASE_URL.SIGN_IN_PASSWORD}`,
{
@@ -13,17 +13,37 @@ export async function login(email: string, password: string) {
)
return login
}
export async function getUserId(email: string, password: string) {
try {
const loginInfo = await firebaseLogin(email, password)
return loginInfo.data.localId
} catch {
return
}
}
export async function signUp(email: string, password: string) {
// const base = 'http://localhost:9099/identitytoolkit.googleapis.com/v1';
await axios.post(`${config.FIREBASE_URL.BASE}${config.FIREBASE_URL.SIGNUP}`, {
email,
password,
returnSecureToken: true,
})
console.log('Auth created for', email)
export async function firebaseSignUp(email: string, password: string) {
try {
const response = await axios.post(`${config.FIREBASE_URL.BASE}${config.FIREBASE_URL.SIGNUP}`, {
email,
password,
returnSecureToken: true,
})
const userId = response.data.localId
console.log('User created in Firebase Auth:', {email, userId})
return userId
} catch (err: any) {
if (
err.response?.status === 400 ||
err.response?.data?.error?.message?.includes('EMAIL_EXISTS')
) {
return await getUserId(email, password)
}
if (err.code === 'ECONNREFUSED') return
// throw Error('Firebase emulator not running. Start it with:\n yarn test:e2e:services\n')
console.log(err)
throw err
}
}
export async function deleteAccount(login: any) {

View File

@@ -1,40 +1,11 @@
import axios from 'axios'
import {createSomeNotifications} from 'backend/api/src/create-notification'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import UserAccountInformation from '../backend/utils/userInformation'
import {config} from '../web/SPEC_CONFIG'
import {seedDatabase} from './seedDatabase'
import {seedUser} from './seedDatabase'
async function createAuth(email: string, password: string) {
const base = 'http://localhost:9099/identitytoolkit.googleapis.com/v1'
try {
const response = await axios.post(`${base}/accounts:signUp?key=fake-api-key`, {
email: email,
password: password,
returnSecureToken: true,
})
const userId = response.data.localId
console.log('User created in Firebase Auth:', email, userId)
return userId
} catch (err: any) {
if (
err.response?.status === 400 ||
err.response?.data?.error?.message?.includes('EMAIL_EXISTS')
) {
return
}
if (err.code === 'ECONNREFUSED') return
// throw Error('Firebase emulator not running. Start it with:\n yarn test:e2e:services\n')
console.log(err)
throw err
}
}
async function seedCompatibilityPrompts(pg: any, userId: string | null = null) {
async function seedCompatibilityPrompts(userId: string | null = null) {
// Need some prompts to prevent the onboarding from stopping once it reaches them (just after profile creation)
const compatibilityPrompts = [
{
@@ -54,6 +25,7 @@ async function seedCompatibilityPrompts(pg: any, userId: string | null = null) {
options: {Action: 0, Comedy: 1, Drama: 2},
},
]
const pg = createSupabaseDirectClient()
for (let i = 0; i < compatibilityPrompts.length; i++) {
const {data, error} = await tryCatch(
insert(pg, 'compatibility_prompts', {
@@ -74,33 +46,20 @@ async function seedNotifications() {
type ProfileType = 'basic' | 'medium' | 'full'
;(async () => {
const pg = createSupabaseDirectClient()
//Edit the count seedConfig to specify the amount of each profiles to create
const seedConfig = [
{count: 8, profileType: 'basic' as ProfileType},
{count: 8, profileType: 'medium' as ProfileType},
{count: 8, profileType: 'full' as ProfileType},
{count: 1, profileType: 'basic' as ProfileType},
{count: 1, profileType: 'medium' as ProfileType},
{count: 1, profileType: 'full' as ProfileType},
]
for (const {count, profileType} of seedConfig) {
for (let i = 0; i < count; i++) {
const userInfo = new UserAccountInformation()
if (i == 0 && profileType === 'full') {
// Seed the first profile with deterministic data for the e2e tests
userInfo.name = 'Franklin Buckridge'
userInfo.email = config.USERS.DEV_1.EMAIL
userInfo.password = config.USERS.DEV_1.PASSWORD
}
userInfo.user_id = await createAuth(userInfo.email, userInfo.password)
if (userInfo.user_id) {
console.log('User created in Supabase:', userInfo.email)
await seedDatabase(pg, userInfo, profileType)
}
await seedUser(undefined, undefined, profileType)
}
}
await seedCompatibilityPrompts(pg)
await seedCompatibilityPrompts()
await seedNotifications()
process.exit(0)

View File

@@ -1,23 +1,23 @@
import {debug} from 'common/logger'
import {PrivateUser} from 'common/user'
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
import {cleanUsername} from 'common/util/clean-username'
import {randomString} from 'common/util/random'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {getUser} from 'shared/utils'
import UserAccountInformation from '../backend/utils/userInformation'
import {firebaseSignUp} from './firebaseUtils'
/**
* Function used to populate the database with profiles.
*
* @param pg - Supabase client used to access the database.
* @param userInfo - Class object containing information to create a user account generated by `fakerjs`.
* @param profileType - Optional param used to signify how much information is used in the account generation.
*/
export async function seedDatabase(
pg: any,
userInfo: UserAccountInformation,
profileType?: string,
) {
export async function seedDbUser(userInfo: UserAccountInformation, profileType?: string) {
const pg = createSupabaseDirectClient()
const userId = userInfo.user_id
const deviceToken = randomString()
const bio = {
@@ -84,6 +84,9 @@ export async function seedDatabase(
}
await pg.tx(async (tx: any) => {
const preexistingUser = await getUser(userId, tx)
if (preexistingUser) return
await insert(tx, 'users', {
id: userId,
name: userInfo.name,
@@ -99,3 +102,18 @@ export async function seedDatabase(
await insert(tx, 'profiles', profileData)
})
}
export async function seedUser(
email?: string | undefined,
password?: string | undefined,
profileType?: string | undefined,
) {
const userInfo = new UserAccountInformation()
if (email) userInfo.email = email
if (password) userInfo.password = password
userInfo.user_id = await firebaseSignUp(userInfo.email, userInfo.password)
if (userInfo.user_id) {
await seedDbUser(userInfo, profileType ?? 'full')
}
debug('User created in Firebase and Supabase:', userInfo.email)
}

View File

@@ -1,31 +0,0 @@
//Run with:
// export ENVIRONMENT=DEV && npx tsx userCreation.ts
import {createSupabaseDirectClient} from 'shared/supabase/init'
import UserAccountInformation from '../backend/utils/userInformation'
import {seedDatabase} from './seedDatabase'
type ProfileType = 'basic' | 'medium' | 'full'
;(async () => {
const pg = createSupabaseDirectClient()
//Edit the count seedConfig to specify the amount of each profiles to create
const seedConfig = [
{count: 1, profileType: 'basic' as ProfileType},
{count: 1, profileType: 'medium' as ProfileType},
{count: 1, profileType: 'full' as ProfileType},
]
for (const {count, profileType} of seedConfig) {
for (let i = 0; i < count; i++) {
const userInfo = new UserAccountInformation()
if (i == 0) {
// Seed the first profile with deterministic data for the e2e tests
userInfo.name = 'Franklin Buckridge'
}
await seedDatabase(pg, userInfo, profileType)
}
}
process.exit(0)
})()

View File

@@ -1,6 +1,6 @@
import {expect, Page, test as base} from '@playwright/test'
import {signUp} from '../../utils/firebaseUtils'
import {seedUser} from '../../utils/seedDatabase'
import {AuthPage} from '../pages/AuthPage'
import {config} from '../SPEC_CONFIG'
@@ -14,7 +14,7 @@ export const test = base.extend<{
const password = config.USERS.DEV_1.PASSWORD
try {
await signUp(email, password)
await seedUser(email, password)
} catch (_e) {
console.log('User already exists for signinFixture', email)
}

View File

@@ -1,9 +1,9 @@
import {deleteFromDb} from '../../utils/databaseUtils'
import {deleteAccount, login} from '../../utils/firebaseUtils'
import {deleteAccount, firebaseLogin} from '../../utils/firebaseUtils'
export async function deleteUser(email: string, password: string) {
try {
const loginInfo = await login(email, password)
const loginInfo = await firebaseLogin(email, password)
await deleteFromDb(loginInfo.data.localId)
await deleteAccount(loginInfo)
} catch (err: any) {

View File

@@ -220,7 +220,6 @@ Catches React errors and shows user-friendly message:
```tsx
import {ErrorBoundary} from 'web/components/error-boundary'
;<ErrorBoundary>
<MyComponent />
</ErrorBoundary>
@@ -250,7 +249,6 @@ Keyboard users can skip to main content:
```tsx
import {SkipLink, MainContent} from 'web/components/skip-link'
;<>
<SkipLink />
<MainContent>...</MainContent>

View File

@@ -14,8 +14,9 @@ import {auth} from 'web/lib/firebase/users'
import {getLocale} from 'web/lib/locale-cookie'
import {identifyUser, setUserProperty} from 'web/lib/service/analytics'
import {getPrivateUserSafe, getUserSafe} from 'web/lib/supabase/users'
import {getCookie, setCookie} from 'web/lib/util/cookie'
import {setCookie} from 'web/lib/util/cookie'
import {safeLocalStorage} from 'web/lib/util/local'
import {isOnboardingFlag} from 'web/lib/util/signup'
// Either we haven't looked up the logged-in user yet (undefined), or we know
// the user is not logged in (null), or we know the user is logged in.
@@ -33,18 +34,19 @@ export const ensureDeviceToken = () => {
}
return deviceToken
}
const getAdminToken = () => {
const key = 'TEST_CREATE_USER_KEY'
const cookie = getCookie(key)
if (cookie) return cookie.replace(/"/g, '')
// For our convenience. If there's a token in local storage, set it as a cookie
const localStorageToken = safeLocalStorage?.getItem(key)
if (localStorageToken) {
setCookie(key, localStorageToken.replace(/"/g, ''))
}
return localStorageToken?.replace(/"/g, '') ?? ''
}
// const getAdminToken = () => {
// const key = 'TEST_CREATE_USER_KEY'
// const cookie = getCookie(key)
// if (cookie) return cookie.replace(/"/g, '')
//
// // For our convenience. If there's a token in local storage, set it as a cookie
// const localStorageToken = safeLocalStorage?.getItem(key)
// if (localStorageToken) {
// setCookie(key, localStorageToken.replace(/"/g, ''))
// }
// return localStorageToken?.replace(/"/g, '') ?? ''
// }
const stripUserData = (user: object) => {
// there's some risk that this cookie could be too big for some clients,
@@ -107,6 +109,13 @@ export function useAndSetupFirebaseUser() {
export const AuthContext = createContext<AuthUser>(undefined)
// function getSupabaseAuthCall() {
// return api('get-supabase-token').catch((e) => {
// console.error('Error getting supabase token', e)
// return null
// })
// }
export function AuthProvider(props: {children: ReactNode; serverUser?: AuthUser}) {
const {children, serverUser} = props
@@ -149,6 +158,12 @@ export function AuthProvider(props: {children: ReactNode; serverUser?: AuthUser}
}
}, [authUser])
// function updateSupabase() {
// When testing on a mobile device, we'll be pointed at a local ip or ngrok address, so this will fail
// Skipping for now as it seems to work fine without it
// if (supabaseJwt) updateSupabaseAuth(supabaseJwt.jwt)
// }
const onAuthLoad = (fbUser: FirebaseUser, user: User, privateUser: PrivateUser) => {
setUser(user)
setPrivateUser(privateUser)
@@ -162,48 +177,40 @@ export function AuthProvider(props: {children: ReactNode; serverUser?: AuthUser}
}
}
function onAuthLoggedOut() {
// User logged out; reset to null
setUserCookie(undefined)
setUser(null)
setPrivateUser(undefined)
// Clear local storage only if we were signed in, otherwise we'll clear referral info
if (safeLocalStorage?.getItem(CACHED_USER_KEY)) localStorage.clear()
}
useEffect(() => {
return onIdTokenChanged(
auth,
async (fbUser) => {
if (fbUser) {
setUserCookie(fbUser.toJSON())
const [user, privateUser] = await Promise.all([
getUserSafe(fbUser.uid),
getPrivateUserSafe(),
// api('get-supabase-token').catch((e) => {
// console.error('Error getting supabase token', e)
// return null
// }),
])
// When testing on a mobile device, we'll be pointed at a local ip or ngrok address, so this will fail
// Skipping for now as it seems to work fine without it
// if (supabaseJwt) updateSupabaseAuth(supabaseJwt.jwt)
if (!user || !privateUser) {
const deviceToken = ensureDeviceToken()
const adminToken = getAdminToken()
const locale = getLocale()
debug('create-user locale', locale)
const newUser = (await api('create-user', {
deviceToken,
adminToken,
locale,
})) as UserAndPrivateUser
onAuthLoad(fbUser, newUser.user, newUser.privateUser)
if (isOnboardingFlag()) {
debug(
'Logged into firebase but onboarding, skipping auth load until onboarding is complete',
)
} else {
onAuthLoad(fbUser, user, privateUser)
const [user, privateUser] = await Promise.all([
getUserSafe(fbUser.uid),
getPrivateUserSafe(),
// getSupabaseAuthCall(),
])
// updateSupabase()
if (user && privateUser) {
onAuthLoad(fbUser, user, privateUser)
} else {
debug('Logged into firebase but user not found in db, should redirect to /onboarding')
}
}
} else {
// User logged out; reset to null
setUserCookie(undefined)
setUser(null)
setPrivateUser(undefined)
// Clear local storage only if we were signed in, otherwise we'll clear referral info
if (safeLocalStorage?.getItem(CACHED_USER_KEY)) localStorage.clear()
onAuthLoggedOut()
}
},
(e) => {

View File

@@ -6,8 +6,7 @@ import {formatFilters, SKIPPED_FORMAT_FILTERS_KEYS} from 'common/filters-format'
import {Gender} from 'common/gender'
import {OptionTableKey} from 'common/profiles/constants'
import {Profile} from 'common/profiles/profile'
import {nullifyDictValues, sampleDictByPrefix} from 'common/util/array'
import {removeNullOrUndefinedProps} from 'common/util/object'
import {nullifyDictValues, removeNullOrUndefinedProps, sampleDictByPrefix} from 'common/util/object'
import {ReactNode, useState} from 'react'
import {
Big5Filters,

View File

@@ -11,7 +11,7 @@ import {useUser} from 'web/hooks/use-user'
import {firebaseLogout} from 'web/lib/firebase/users'
import {useT} from 'web/lib/locale'
import {withTracking} from 'web/lib/service/analytics'
import {signupRedirect} from 'web/lib/util/signup'
import {startSignup} from 'web/lib/util/signup'
import {isAndroidApp} from 'web/lib/util/webview'
import SiteLogo from '../site-logo'
@@ -123,7 +123,7 @@ export const SignUpButton = (props: {
data-testid="side-bar-sign-up-button"
color={color ?? 'gradient'}
size={size ?? 'xl'}
onClick={signupRedirect}
onClick={startSignup}
className={clsx('w-full', className)}
>
{text ?? t('nav.sign_up_now', 'Sign up now')}

View File

@@ -14,17 +14,12 @@ import {
RELIGION_CHOICES,
ROMANTIC_CHOICES,
} from 'common/choices'
import {IS_PROD} from 'common/envs/constants'
import {debug} from 'common/logger'
import {MultipleChoiceOptions} from 'common/profiles/multiple-choice'
import {getProfileRow, Profile, ProfileWithoutUser} from 'common/profiles/profile'
import {Profile, ProfileWithoutUser} from 'common/profiles/profile'
import {PLATFORM_LABELS, type Site, SITE_ORDER} from 'common/socials'
import {User} from 'common/user'
import {removeUndefinedProps} from 'common/util/object'
import {sleep} from 'common/util/time'
import {tryCatch} from 'common/util/try-catch'
import {isEqual, range} from 'lodash'
import {useRouter} from 'next/router'
import {BaseUser} from 'common/user'
import {range} from 'lodash'
import {Fragment, useEffect, useRef, useState} from 'react'
import Textarea from 'react-expanding-textarea'
import toast from 'react-hot-toast'
@@ -44,8 +39,6 @@ import {Select} from 'web/components/widgets/select'
import {Slider} from 'web/components/widgets/slider'
import {Title} from 'web/components/widgets/title'
import {fetchChoices} from 'web/hooks/use-choices'
import {useProfileDraft} from 'web/hooks/use-profile-draft'
import {api, updateProfile, updateUser} from 'web/lib/api'
import {useLocale, useT} from 'web/lib/locale'
import {track} from 'web/lib/service/analytics'
import {db} from 'web/lib/supabase/db'
@@ -57,18 +50,18 @@ import {AddPhotosWidget} from './widgets/add-photos'
export const OptionalProfileUserForm = (props: {
profile: ProfileWithoutUser
setProfile: <K extends keyof ProfileWithoutUser>(key: K, value: ProfileWithoutUser[K]) => void
user: User
user: BaseUser
setUser: <K extends keyof BaseUser>(key: K, value: BaseUser[K]) => void
buttonLabel?: string
bottomNavBarVisible?: boolean
fromSignup?: boolean
onSubmit?: () => Promise<void>
onSubmit: () => Promise<void>
}) => {
const {
profile,
user,
buttonLabel,
setProfile,
fromSignup,
setUser,
onSubmit,
bottomNavBarVisible = true,
} = props
@@ -78,7 +71,6 @@ export const OptionalProfileUserForm = (props: {
(profile.pref_relation_styles || []).includes('relationship'),
)
const [ageError, setAgeError] = useState<string | null>(null)
const router = useRouter()
const t = useT()
const [heightFeet, setHeightFeet] = useState<number | undefined>(
profile.height_in_inches ? Math.floor((profile['height_in_inches'] ?? 0) / 12) : undefined,
@@ -87,20 +79,6 @@ export const OptionalProfileUserForm = (props: {
profile.height_in_inches ? Math.floor((profile['height_in_inches'] ?? 0) % 12) : undefined,
)
// Keep local feet/inches inputs in sync when profile.height_in_inches updates
// This covers cases like hydration from localStorage where setProfile is called externally
const updateHeight = (h: any) => {
if (h == null || Number.isNaN(h as any)) {
setHeightFeet(undefined)
setHeightInches(undefined)
return
}
setHeightFeet(Math.floor(h / 12))
setHeightInches(Math.round(h % 12))
}
const [newLinks, setNewLinks] = useState<Record<string, string | null>>(user.link)
const [newLinkPlatform, setNewLinkPlatform] = useState('')
const [newLinkValue, setNewLinkValue] = useState('')
const [interestChoices, setInterestChoices] = useState({})
@@ -110,8 +88,6 @@ export const OptionalProfileUserForm = (props: {
const [keywordsString, setKeywordsString] = useState<string>(profile.keywords?.join(', ') || '')
const {clearProfileDraft} = useProfileDraft(user.id, profile, setProfile, updateHeight)
useEffect(() => {
fetchChoices('interests', locale).then(setInterestChoices)
fetchChoices('causes', locale).then(setCauseChoices)
@@ -140,78 +116,16 @@ export const OptionalProfileUserForm = (props: {
}
setIsSubmitting(true)
const {
// bio: _bio,
// bio_text: _bio_text,
// bio_tsv: _bio_tsv,
// bio_length: _bio_length,
interests,
causes,
work,
...otherProfileProps
} = profile
debug('otherProfileProps', removeUndefinedProps(otherProfileProps))
const promises: Promise<any>[] = [
tryCatch(updateProfile(removeUndefinedProps(otherProfileProps) as any)),
]
if (interests) {
promises.push(api('update-options', {table: 'interests', values: interests}))
}
if (causes) {
promises.push(api('update-options', {table: 'causes', values: causes}))
}
if (work) {
promises.push(api('update-options', {table: 'work', values: work}))
}
try {
await Promise.all(promises)
// Clear profile draft from Zustand store after successful submission
clearProfileDraft(user.id)
} catch (error) {
console.error(error)
toast.error(
`We ran into an issue saving your profile. Please try again or contact us if the issue persists.`,
)
setIsSubmitting(false)
return
}
if (!isEqual(newLinks, user.link)) {
const {error} = await tryCatch(updateUser({link: newLinks}))
if (error) {
console.error(error)
return
}
}
if (onSubmit) {
await onSubmit()
}
track('submit optional profile')
if (user) {
let profile
let i = 1
const start = Date.now()
while (Date.now() - start < 10000) {
profile = await getProfileRow(user.id, db)
if (profile) {
console.log(`Found profile after ${Date.now() - start} ms, ${i} attempts`)
break
}
await sleep(500)
i++
}
if (profile) {
if (IS_PROD) await sleep(5000) // attempt to mitigate profile not found at /${username} upon creation
router.push(`/${user.username}${fromSignup ? '?fromSignup=true' : ''}`)
} else {
console.log('Profile not found after fetching, going back home...')
router.push('/')
}
} else router.push('/')
await onSubmit()
setIsSubmitting(false)
}
const updateUserLink = (platform: string, value: string | null) => {
setNewLinks((links) => ({...links, [platform]: value}))
setUser('link', {...user.link, [platform]: value})
}
const addNewLink = () => {
@@ -997,7 +911,7 @@ export const OptionalProfileUserForm = (props: {
{/*</label>*/}
<div className="grid w-full grid-cols-[8rem_1fr_auto] gap-2">
{Object.entries(newLinks)
{Object.entries(user.link)
.filter(([_, value]) => value != null)
.map(([platform, value]) => (
<Fragment key={platform}>
@@ -1084,7 +998,7 @@ export const OptionalProfileUserForm = (props: {
{/*</div>*/}
<AddPhotosWidget
user={user}
username={user.username}
photo_urls={profile.photo_urls}
pinned_url={profile.pinned_url}
setPhotoUrls={(urls) => setProfile('photo_urls', urls)}

View File

@@ -1,14 +1,14 @@
import clsx from 'clsx'
import {ProfileRow, ProfileWithoutUser} from 'common/profiles/profile'
import {User} from 'common/user'
import {useEffect, useState} from 'react'
import {APIError} from 'common/api/utils'
import {debug} from 'common/logger'
import {useState} from 'react'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {Input} from 'web/components/widgets/input'
import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
import {Title} from 'web/components/widgets/title'
import {useEditableUserInfo} from 'web/hooks/use-editable-user-info'
import {api} from 'web/lib/api'
import {useT} from 'web/lib/locale'
import {labelClassName} from 'web/pages/signup'
@@ -30,55 +30,51 @@ export const initialRequiredState = {
languages: [],
bio: null,
}
// const requiredKeys = Object.keys(
// initialRequiredState
// ) as (keyof typeof initialRequiredState)[]
export type RequiredFormData = {
name: string
username: string
}
export const RequiredProfileUserForm = (props: {
user: User
// TODO thread this properly instead of this jank
setEditUsername?: (name: string) => unknown
setEditDisplayName?: (name: string) => unknown
profile: ProfileRow
setProfile: <K extends keyof ProfileWithoutUser>(
data: RequiredFormData
setData: <K extends keyof RequiredFormData>(
key: K,
value: ProfileWithoutUser[K] | undefined,
value: RequiredFormData[K] | undefined,
) => void
isSubmitting: boolean
isLocked?: boolean
onSubmit?: () => void
profileCreatedAlready?: boolean
}) => {
const {user, onSubmit, profileCreatedAlready, isSubmitting, isLocked} = props
const {updateDisplayName, userInfo, updateUserState, updateUsername} = useEditableUserInfo(user)
const {onSubmit, profileCreatedAlready, data, setData} = props
const [step, setStep] = useState<number>(0)
const [loadingUsername, setLoadingUsername] = useState<boolean>(false)
const [isSubmitting, setIsSubmitting] = useState<boolean>(false)
const [errorUsername, setErrorUsername] = useState<string>('')
const t = useT()
const {name, username, errorUsername, loadingUsername, loadingName, errorName} = userInfo
useEffect(() => {
if (props.setEditUsername) props.setEditUsername(username)
}, [username])
useEffect(() => {
if (props.setEditDisplayName) props.setEditDisplayName(name)
}, [name])
const canContinue = true
// const canContinue =
// (!profile.looking_for_matches ||
// requiredKeys
// .map((k) => profile[k])
// .every((v) =>
// typeof v == 'string'
// ? v !== ''
// : Array.isArray(v)
// ? v.length > 0
// : v !== undefined
// )) &&
// !loadingUsername &&
// !loadingName
const updateUsername = async () => {
let success = true
setLoadingUsername(true)
try {
const {
valid,
message = undefined,
suggestedUsername,
} = await api('validate-username', {username: data.username})
if (valid) {
setData('username', suggestedUsername)
} else {
setErrorUsername(message || 'Unknown error')
success = false
}
} catch (reason) {
setErrorUsername((reason as APIError).message)
success = false
}
setLoadingUsername(false)
debug('Username:', data.username)
return success
}
return (
<>
@@ -88,14 +84,6 @@ export const RequiredProfileUserForm = (props: {
{/* {t('profile.basics.subtitle', 'Write your own bio, your own way.')}*/}
{/* </div>*/}
{/*)}*/}
{isLocked && (
<div className="mb-6 text-lg">
{t(
'profile.required.username_locked_warning',
'You cannot change your username after creating a profile, but you can update your name later in your profile settings.',
)}
</div>
)}
<Col className={'gap-8 pb-[env(safe-area-inset-bottom)] w-fit'}>
{(step === 0 || profileCreatedAlready) && (
<Col>
@@ -104,18 +92,15 @@ export const RequiredProfileUserForm = (props: {
</label>
<Row className={'items-center gap-2'}>
<Input
disabled={loadingName || isLocked}
disabled={false}
type="text"
placeholder="Display name"
value={name}
value={data.name || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
updateUserState({name: e.target.value || ''})
setData('name', e.target.value || '')
}}
onBlur={updateDisplayName}
/>
{loadingName && <LoadingIndicator className={'ml-2'} />}
</Row>
{errorName && <span className="text-error text-sm">{errorName}</span>}
</Col>
)}
@@ -128,15 +113,12 @@ export const RequiredProfileUserForm = (props: {
</label>
<Row className={'items-center gap-2'}>
<Input
disabled={isLocked}
disabled={loadingUsername}
type="text"
placeholder="Username"
value={username}
value={data.username || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
updateUserState({
username: e.target.value || '',
errorUsername: '',
})
setData('username', e.target.value || '')
}}
/>
{loadingUsername && <LoadingIndicator className={'ml-2'} />}
@@ -172,9 +154,10 @@ export const RequiredProfileUserForm = (props: {
{onSubmit && (
<Row className={'justify-end'}>
<Button
disabled={!canContinue || isSubmitting || loadingUsername}
disabled={isSubmitting}
loading={isSubmitting}
onClick={async () => {
setIsSubmitting(true)
let success = true
if (step === 0) {
success = await updateUsername()
@@ -186,6 +169,7 @@ export const RequiredProfileUserForm = (props: {
setStep(step + 1)
}
}
setIsSubmitting(false)
}}
>
{t('common.next', 'Next')}

View File

@@ -1,7 +1,6 @@
import {CheckCircleIcon} from '@heroicons/react/24/outline'
import {PlusIcon, XMarkIcon} from '@heroicons/react/24/solid'
import clsx from 'clsx'
import {User} from 'common/user'
import {buildArray} from 'common/util/array'
import {uniq} from 'lodash'
import Image from 'next/image'
@@ -13,7 +12,7 @@ import {uploadImage} from 'web/lib/firebase/storage'
import {useT} from 'web/lib/locale'
export const AddPhotosWidget = (props: {
user: User
username: string
image_descriptions: Record<string, string> | null
photo_urls: string[] | null
pinned_url: string | null
@@ -22,7 +21,7 @@ export const AddPhotosWidget = (props: {
setDescription: (url: string, description: string) => void
}) => {
const {
user,
username,
photo_urls,
pinned_url,
setPhotoUrls,
@@ -43,7 +42,7 @@ export const AddPhotosWidget = (props: {
const selectedFiles = Array.from(files).slice(0, 6)
const urls = await Promise.all(
selectedFiles.map((f) => uploadImage(user.username, f, 'love-images')),
selectedFiles.map((f) => uploadImage(username, f, 'love-images')),
).catch((e) => {
console.error(e)
return []

View File

@@ -1,77 +0,0 @@
import {APIError} from 'common/api/utils'
import {User} from 'common/user'
import {cleanDisplayName, cleanUsername} from 'common/util/clean-username'
import {useState} from 'react'
import {updateUser} from 'web/lib/api'
type UserInfoState = {
name: string
username: string
loadingName: boolean
loadingUsername: boolean
errorName: string
errorUsername: string
}
export const useEditableUserInfo = (user: User) => {
const [userInfo, setUserInfo] = useState<UserInfoState>({
name: user.name,
username: user.username,
loadingName: false,
loadingUsername: false,
errorName: '',
errorUsername: '',
})
const updateUserState = (newState: Partial<UserInfoState>) => {
setUserInfo((prevState) => ({...prevState, ...newState}))
}
const updateDisplayName = async () => {
const newName = cleanDisplayName(userInfo.name)
if (newName === user.name) return
updateUserState({loadingName: true, errorName: ''})
if (!newName) return updateUserState({name: user.name})
try {
await updateUser({name: newName})
updateUserState({errorName: '', name: newName})
} catch (reason) {
updateUserState({
errorName: (reason as APIError).message,
name: user.name,
})
}
updateUserState({loadingName: false})
}
const updateUsername = async () => {
const newUsername = cleanUsername(userInfo.username)
// console.log({newUsername})
if (newUsername === user.username) return true
updateUserState({loadingUsername: true, errorUsername: ''})
let success = true
try {
await updateUser({username: newUsername})
updateUserState({errorUsername: '', username: newUsername})
user.username = newUsername
} catch (reason) {
updateUserState({errorUsername: (reason as APIError).message})
success = false
}
updateUserState({loadingUsername: false})
return success
}
return {
userInfo,
updateDisplayName,
updateUserState,
updateUsername,
}
}

View File

@@ -9,7 +9,7 @@ export function initSupabaseClient() {
const anonKeyOverride = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY
if (urlOverride && anonKeyOverride) {
console.log('Initializing Supabase client (env URL override)', {urlOverride, anonKeyOverride})
// debug('Initializing Supabase client (env URL override)', {urlOverride, anonKeyOverride})
return createClient(urlOverride, anonKeyOverride)
} else if (process.env.NEXT_PUBLIC_ISOLATED_ENV) {
throw new Error(

View File

@@ -1,27 +1,49 @@
import {debug} from 'common/logger'
import {getProfileRow} from 'common/profiles/profile'
import Router from 'next/router'
import toast from 'react-hot-toast'
import {firebaseLogin} from 'web/lib/firebase/users'
import {db} from 'web/lib/supabase/db'
import {safeLocalStorage} from 'web/lib/util/local'
export const signupThenMaybeRedirectToSignup = async () => {
export function setOnboardingFlag() {
debug('setOnboardingFlag')
safeLocalStorage?.setItem(`is-onboarding`, 'true')
}
export function clearOnboardingFlag() {
debug('clearOnboardingFlag')
safeLocalStorage?.removeItem(`is-onboarding`)
}
export function isOnboardingFlag() {
debug('isOnboardingFlag')
return safeLocalStorage?.getItem(`is-onboarding`)
}
export const googleSigninSignup = async () => {
try {
setOnboardingFlag()
const creds = await firebaseLogin()
const userId = creds?.user.uid
if (userId) {
const profile = await getProfileRow(userId, db)
if (profile) {
await Router.push('/')
} else {
await Router.push('/onboarding')
}
}
await postSignupRedirect(creds)
} catch (e: any) {
console.error(e)
toast.error('Failed to sign in: ' + e.message)
}
}
export async function signupRedirect() {
export async function startSignup() {
await Router.push('/register')
}
export async function postSignupRedirect(creds: any) {
const userId = creds?.user?.uid
if (userId) {
const profile = await getProfileRow(userId, db)
if (profile) {
await Router.push('/')
} else {
await Router.push('/onboarding')
}
}
}

View File

@@ -17,6 +17,7 @@ const nextConfig: NextConfig = {
},
transpilePackages: ['common'],
experimental: {
serverSourceMaps: true,
scrollRestoration: true,
turbopackFileSystemCacheForDev: true, // filesystem cache for faster dev rebuilds
},

View File

@@ -14,6 +14,7 @@
"prettier": "npx prettier --write .",
"prod": "cross-env NEXT_PUBLIC_FIREBASE_ENV=PROD concurrently -n NEXT,TS -c magenta,cyan \"yarn serve\" \"yarn ts-watch\"",
"serve": "next dev -H 0.0.0.0 -p 3000",
"serve:debug": "NODE_OPTIONS='--inspect=0.0.0.0:9229' yarn serve",
"start": "next start",
"test": "jest --config jest.config.js --passWithNoTests",
"ts-watch": "tsc --watch --noEmit --incremental --preserveWatchOutput --pretty",

View File

@@ -1,4 +1,5 @@
import {JSONContent} from '@tiptap/core'
import {RESERVED_PATHS} from 'common/envs/constants'
import {debug} from 'common/logger'
import {getProfileOgImageUrl} from 'common/profiles/og-image'
import {getProfileRow, ProfileRow} from 'common/profiles/profile'
@@ -78,7 +79,13 @@ export const getStaticProps = async (
) => {
const {username} = props.params!
console.log('Starting getStaticProps in /[username]')
// Skip DB lookup entirely for known non-user routes
// Also block file extension requests — these are never valid usernames
if (RESERVED_PATHS.has(username.toLowerCase()) || username.includes('.')) {
return {notFound: true}
}
console.log('Starting getStaticProps in /[username]', username)
const user = await getUser(username)

View File

@@ -1,7 +1,11 @@
import {debug} from 'common/logger'
import {Profile, ProfileWithoutUser} from 'common/profiles/profile'
import {User} from 'common/user'
import {BaseUser, User} from 'common/user'
import {filterDefined} from 'common/util/array'
import {removeUndefinedProps} from 'common/util/object'
import Router from 'next/router'
import {useEffect, useState} from 'react'
import toast from 'react-hot-toast'
import {Col} from 'web/components/layout/col'
import {OptionalProfileUserForm} from 'web/components/optional-profile-form'
import {PageBase} from 'web/components/page-base'
@@ -10,7 +14,7 @@ import {SEO} from 'web/components/SEO'
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
import {useProfileByUser} from 'web/hooks/use-profile'
import {useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {api, updateProfile, updateUser} from 'web/lib/api'
import {useT} from 'web/lib/locale'
export default function ProfilePage() {
@@ -60,8 +64,34 @@ function ProfilePageInner(props: {user: User; profile: Profile}) {
setProfile((prevState) => ({...prevState, [key]: value}))
}
const [displayName, setDisplayName] = useState(user.name)
const [username, setUsername] = useState(user.username)
const [baseUser, setBaseUser] = useState<BaseUser>(user)
const setBaseUserState = <K extends keyof BaseUser>(key: K, value: BaseUser[K] | undefined) => {
setBaseUser((prevState) => ({...prevState, [key]: value}))
}
async function submitForm() {
const {interests, causes, work, ...otherProfileProps} = profile
const parsedProfile = removeUndefinedProps(otherProfileProps) as any
debug('parsedProfile', parsedProfile)
const promises: Promise<any>[] = filterDefined([
updateProfile(parsedProfile),
baseUser && updateUser(baseUser),
interests && api('update-options', {table: 'interests', values: interests}),
causes && api('update-options', {table: 'causes', values: causes}),
work && api('update-options', {table: 'work', values: work}),
])
try {
await Promise.all(promises)
} catch (error) {
console.error(error)
toast.error(
`We ran into an issue saving your profile. Please try again or contact us if the issue persists.`,
)
return
}
Router.push(`/${user.username}`)
}
return (
<PageBase trackPageView={'profile'}>
@@ -73,26 +103,18 @@ function ProfilePageInner(props: {user: User; profile: Profile}) {
<Col className="items-center">
<Col className={'w-full px-6 py-4'}>
<RequiredProfileUserForm
user={user}
setProfile={setProfileState}
profile={profile}
profileCreatedAlready={true}
isSubmitting={false}
setEditUsername={setUsername}
setEditDisplayName={setDisplayName}
data={baseUser}
setData={setBaseUserState}
profileCreatedAlready
/>
<div className={'h-4'} />
<OptionalProfileUserForm
profile={profile}
user={user}
setProfile={setProfileState}
user={baseUser}
setUser={setBaseUserState}
buttonLabel={t('profile.save', 'Save')}
onSubmit={async () => {
api('me/update', {
name: displayName === user.name ? undefined : displayName,
username: username === user.username ? undefined : username,
})
}}
onSubmit={async () => await submitForm()}
/>
</Col>
</Col>

View File

@@ -1,22 +1,18 @@
'use client'
import {debug} from 'common/logger'
import {getProfileRow} from 'common/profiles/profile'
import {createUserWithEmailAndPassword} from 'firebase/auth'
import Link from 'next/link'
import {useSearchParams} from 'next/navigation'
import Router from 'next/router'
import React, {Suspense, useEffect, useState} from 'react'
import React, {Suspense, useState} from 'react'
import toast from 'react-hot-toast'
import {GoogleButton} from 'web/components/buttons/sign-up-button'
import FavIcon from 'web/components/FavIcon'
import {PageBase} from 'web/components/page-base'
import {SEO} from 'web/components/SEO'
import {useUser} from 'web/hooks/use-user'
import {auth} from 'web/lib/firebase/users'
import {useT} from 'web/lib/locale'
import {db} from 'web/lib/supabase/db'
import {signupThenMaybeRedirectToSignup} from 'web/lib/util/signup'
import {googleSigninSignup, postSignupRedirect, setOnboardingFlag} from 'web/lib/util/signup'
export default function RegisterPage() {
return (
@@ -35,34 +31,23 @@ function RegisterComponent() {
const [isLoading, setIsLoading] = useState(false)
const [registrationSuccess, setRegistrationSuccess] = useState(false)
const [registeredEmail, _] = useState('')
const user = useUser()
// function redirect() {
// // Redirect to complete profile page
// window.location.href = href
// }
useEffect(() => {
const checkProfileAndRedirect = async () => {
if (user) {
const profile = await getProfileRow(user.id, db)
if (profile) {
console.log("Router.push('/')")
await Router.push('/')
} else {
console.log("Router.push('/onboarding')")
await Router.push('/onboarding')
}
setIsLoading(false)
}
}
checkProfileAndRedirect()
}, [user])
const checkProfileAndRedirect = async (creds: any) => {
await postSignupRedirect(creds)
setIsLoading(false)
}
const handleEmailPasswordSignUp = async (email: string, password: string) => {
try {
setOnboardingFlag()
const creds = await createUserWithEmailAndPassword(auth, email, password)
debug('User signed up:', creds.user)
await checkProfileAndRedirect(creds)
} catch (error: any) {
console.error('Error signing up:', error)
toast.error(t('register.toast.signup_failed', 'Failed to sign up: ') + (error?.message ?? ''))
@@ -253,7 +238,7 @@ function RegisterComponent() {
</span>
</div>
</div>
<GoogleButton onClick={signupThenMaybeRedirectToSignup} isLoading={isLoading} />
<GoogleButton onClick={googleSigninSignup} isLoading={isLoading} />
</div>
</form>
<div className="my-8" />

View File

@@ -42,25 +42,26 @@ function RegisterComponent() {
}
}, [searchParams])
useEffect(() => {
const checkAndRedirect = async () => {
if (user) {
debug('User signed in:', user)
try {
const profile = await getProfileRow(user.id, db)
if (profile) {
await Router.push('/')
} else {
await Router.push('/onboarding')
}
} catch (error) {
console.error('Error fetching profile profile:', error)
const checkAndRedirect = async (userId: string | undefined) => {
if (userId) {
debug('User signed in:', userId)
try {
const profile = await getProfileRow(userId, db)
if (profile) {
await Router.push('/')
} else {
await Router.push('/onboarding')
}
setIsLoading(false)
setIsLoadingGoogle(false)
} catch (error) {
console.error('Error fetching profile profile:', error)
}
setIsLoading(false)
setIsLoadingGoogle(false)
}
checkAndRedirect()
}
useEffect(() => {
checkAndRedirect(user?.id)
}, [user])
const handleGoogleSignIn = async () => {
@@ -72,6 +73,7 @@ function RegisterComponent() {
if (creds) {
setIsLoading(true)
setIsLoadingGoogle(true)
await checkAndRedirect(creds?.user?.uid)
}
} catch (error) {
console.error('Error signing in:', error)
@@ -85,6 +87,7 @@ function RegisterComponent() {
const handleEmailPasswordSignIn = async (email: string, password: string) => {
try {
const creds = await signInWithEmailAndPassword(auth, email, password)
await checkAndRedirect(creds?.user?.uid)
debug(creds)
} catch (error) {
console.error('Error signing in:', error)

View File

@@ -1,61 +1,46 @@
import {LOCALE_TO_LANGUAGE} from 'common/choices'
import {debug} from 'common/logger'
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, useRef, useState} from 'react'
import {Toaster} from 'react-hot-toast'
import {useEffect, useState} from 'react'
import toast, {Toaster} from 'react-hot-toast'
import {ensureDeviceToken} from 'web/components/auth-context'
import {Col} from 'web/components/layout/col'
import {OptionalProfileUserForm} from 'web/components/optional-profile-form'
import {initialRequiredState, RequiredProfileUserForm} from 'web/components/required-profile-form'
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
import {useProfileByUserId} from 'web/hooks/use-profile'
import {useTracking} from 'web/hooks/use-tracking'
import {useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {CACHED_REFERRAL_USERNAME_KEY} from 'web/lib/firebase/users'
import {auth, CACHED_REFERRAL_USERNAME_KEY} from 'web/lib/firebase/users'
import {useLocale} from 'web/lib/locale'
import {getLocale} from 'web/lib/locale-cookie'
import {track} from 'web/lib/service/analytics'
import {safeLocalStorage} from 'web/lib/util/local'
import {clearOnboardingFlag} from 'web/lib/util/signup'
export default function SignupPage() {
const [step, setStep] = useState(0)
const user = useUser()
// console.debug('user:', user)
const router = useRouter()
useTracking('view signup page')
// Hold loading indicator for 5s when user transitions from undefined -> null
const prevUserRef = useRef<ReturnType<typeof useUser>>(undefined)
const holdTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [holdLoading, setHoldLoading] = useState(true)
useEffect(() => {
const prev = prevUserRef.current
// Transition: undefined -> null
if (prev === undefined && user === null) {
setHoldLoading(true)
if (holdTimeoutRef.current) clearTimeout(holdTimeoutRef.current)
holdTimeoutRef.current = setTimeout(() => {
setHoldLoading(false)
holdTimeoutRef.current = null
}, 10000)
}
// If user becomes defined, stop holding immediately
if (user && holdLoading) {
setHoldLoading(false)
if (holdTimeoutRef.current) {
clearTimeout(holdTimeoutRef.current)
holdTimeoutRef.current = null
}
}
prevUserRef.current = user
}, [user, holdLoading])
const {locale} = useLocale()
const language = LOCALE_TO_LANGUAGE[locale]
// Omit the id, created_time?
const [baseUser, setBaseUser] = useState<BaseUser>(getInitialBaseUser())
const setBaseUserState = <K extends keyof BaseUser>(key: K, value: BaseUser[K] | undefined) => {
setBaseUser((prevState) => ({...prevState, [key]: value}))
}
const username = baseUser.username
const name = baseUser.name
const [profileForm, setProfileForm] = useState<ProfileWithoutUser>({
...initialRequiredState,
languages: language ? [language] : [],
@@ -64,51 +49,14 @@ export default function SignupPage() {
setProfileForm((prevState) => ({...prevState, [key]: value}))
}
const [isSubmitting, setIsSubmitting] = useState(false)
// When a profile already exists (e.g., when the user pressed browser back after submitting),
// lock the username and display name fields so they can't be changed.
const [isLocked, setIsLocked] = useState(false)
const existingProfile = useProfileByUserId(user?.id)
useEffect(() => {
if (existingProfile) {
setProfileForm(existingProfile)
setIsLocked(true)
}
}, [existingProfile])
// if (step === 1 && user) {
// return <PageBase trackPageView={'register'}>
// <SEO
// title={t('signup.seo.title','Sign up')}
// description={t('signup.seo.description','Create a new account')}
// url={`/signup`}
// />
// <Col className={'w-full px-6 py-4'}>
// <OptionalProfileUserForm
// setProfile={setProfileState}
// profile={profileForm}
// user={user}
// fromSignup
// />
// </Col>
// </PageBase>
// }
// Sync the step with browser history so the back button works within the flow.
// When we advance to step 1, push a new history entry. When the user presses
// back, popstate fires and we move back to step 0 rather than leaving the page.
useEffect(() => {
const handlePopState = (e: PopStateEvent) => {
const historyStep = e.state?.signupStep ?? 0
setStep(historyStep)
scrollTo(0, 0)
setIsLocked(true)
}
window.addEventListener('popstate', handlePopState)
// Record step 0 in history on mount so there's always a baseline entry.
window.history.replaceState({signupStep: 0}, '')
return () => {
@@ -116,80 +64,91 @@ export default function SignupPage() {
}
}, [])
useEffect(() => {
if (auth.currentUser?.uid) setBaseUser(getInitialBaseUser())
}, [auth.currentUser?.uid])
const advanceToStep = (nextStep: number) => {
window.history.pushState({signupStep: nextStep}, '')
setStep(nextStep)
scrollTo(0, 0)
}
if (user === null && !holdLoading) {
console.log('user === null && !holdLoading')
return <CompassLoadingIndicator />
const handleFinalSubmit = async () => {
const referredByUsername = safeLocalStorage
? (safeLocalStorage.getItem(CACHED_REFERRAL_USERNAME_KEY) ?? undefined)
: undefined
const locale = getLocale()
const deviceToken = ensureDeviceToken()
try {
const profile = removeNullOrUndefinedProps({
...profileForm,
referred_by_username: referredByUsername,
}) as any
const {interests, causes, work, ...otherProfileProps} = profile
const result = await api('create-user-and-profile', {
username,
name,
locale,
deviceToken,
link: baseUser.link,
profile: otherProfileProps,
})
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()
// 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(3000)
router.push(`/${result.user.username}?fromSignup=true`)
} catch (e) {
console.error(e)
toast.error('An error occurred during signup, try again later...')
}
}
// if (user === null && !holdLoading && !isNewRegistration) {
// return <CompassLoadingIndicator />
// }
const showLoading = false
return (
<Col className="items-center">
<Toaster position={'top-center'} containerClassName="!bottom-[70px]" />
{!user ? (
{showLoading ? (
<CompassLoadingIndicator />
) : (
<Col className={'w-full max-w-4xl px-6 py-4'}>
{step === 0 ? (
<RequiredProfileUserForm
user={user}
setProfile={setProfileState}
profile={profileForm}
isSubmitting={isSubmitting}
// Lock username and display name if the profile was already created.
// This handles the case where the user pressed browser back after
// submitting — they can review and continue, but not re-set these fields.
isLocked={isLocked}
onSubmit={async () => {
if (!profileForm.looking_for_matches) {
router.push('/')
return
}
// If the profile already exists (back-navigation case), skip the
// API call and just advance to the next step.
if (isLocked) {
advanceToStep(1)
console.log('resume signup after back navigation')
return
}
const referredByUsername = safeLocalStorage
? (safeLocalStorage.getItem(CACHED_REFERRAL_USERNAME_KEY) ?? undefined)
: undefined
setIsSubmitting(true)
debug('profileForm', profileForm)
const profile = await api(
'create-profile',
removeNullOrUndefinedProps({
...profileForm,
referred_by_username: referredByUsername,
}) as any,
).catch((e: unknown) => {
console.error(e)
return null
})
setIsSubmitting(false)
if (profile) {
setProfileForm(profile)
advanceToStep(1)
track('submit required profile')
}
}}
data={baseUser}
setData={setBaseUserState}
onSubmit={async () => advanceToStep(1)}
/>
) : step === 1 ? (
<Col className={'w-full px-2 sm:px-6 py-4 mb-2'}>
<OptionalProfileUserForm
setProfile={setProfileState}
profile={profileForm}
user={user}
fromSignup
setProfile={setProfileState}
user={baseUser}
setUser={setBaseUserState}
bottomNavBarVisible={false}
onSubmit={handleFinalSubmit}
/>
</Col>
) : (
@@ -203,3 +162,17 @@ export default function SignupPage() {
export const colClassName = 'items-start gap-2'
export const labelClassName = 'font-semibold text-md'
function getInitialBaseUser() {
const emailName = auth.currentUser?.email?.replace(/@.*$/, '')
const name = cleanDisplayName(
auth.currentUser?.displayName || emailName || 'User' + randomString(4),
)
const initialState = {
id: auth.currentUser?.uid ?? '',
username: cleanUsername(name),
name: name,
link: {},
}
return initialState
}