mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-03-25 01:51:37 -04:00
Merge branch 'collapse_registration'
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
186
backend/api/src/create-user-and-profile.ts
Normal file
186
backend/api/src/create-user-and-profile.ts
Normal 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',
|
||||
]
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
47
backend/api/src/validate-username.ts
Normal file
47
backend/api/src/validate-username.ts
Normal 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
|
||||
}
|
||||
749
backend/api/tests/unit/create-user-and-profile.unit.test.ts
Normal file
749
backend/api/tests/unit/create-user-and-profile.unit.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}, {})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}, {})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})()
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
transpilePackages: ['common'],
|
||||
experimental: {
|
||||
serverSourceMaps: true,
|
||||
scrollRestoration: true,
|
||||
turbopackFileSystemCacheForDev: true, // filesystem cache for faster dev rebuilds
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user