diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index c0dbe3a2..ea9a3936 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -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} = { '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} = { '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, diff --git a/backend/api/src/create-profile.ts b/backend/api/src/create-profile.ts index 4c787884..4daf781a 100644 --- a/backend/api/src/create-profile.ts +++ b/backend/api/src/create-profile.ts @@ -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) diff --git a/backend/api/src/create-user-and-profile.ts b/backend/api/src/create-user-and-profile.ts new file mode 100644 index 00000000..4b798b8d --- /dev/null +++ b/backend/api/src/create-user-and-profile.ts @@ -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(`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', +] diff --git a/backend/api/src/create-user.ts b/backend/api/src/create-user.ts index 37f53fdf..10fb7a5c 100644 --- a/backend/api/src/create-user.ts +++ b/backend/api/src/create-user.ts @@ -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) => { diff --git a/backend/api/src/helpers/endpoint.ts b/backend/api/src/helpers/endpoint.ts index 387b60bc..c67e500b 100644 --- a/backend/api/src/helpers/endpoint.ts +++ b/backend/api/src/helpers/endpoint.ts @@ -238,12 +238,16 @@ function checkRateLimit(name: string, req: Request, res: Response, auth?: Authed } export const typedEndpoint = (name: N, handler: APIHandler) => { + const apiSchema = API[name] as APISchema & { + deprecation?: {deprecated: boolean; migrationPath?: string; sunsetDate?: string} + } const { props: propSchema, authed: authRequired, rateLimited = false, method, - } = API[name] as APISchema + deprecation, + } = apiSchema return async (req: Request, res: Response, next: NextFunction) => { let authUser: AuthedUser | undefined = undefined @@ -262,6 +266,10 @@ export const typedEndpoint = (name: N, handler: APIHandler } } + 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 = (name: N, handler: APIHandler 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) diff --git a/backend/api/src/update-me.ts b/backend/api/src/update-me.ts index 9df47b7a..acea09b1 100644 --- a/backend/api/src/update-me.ts +++ b/backend/api/src/update-me.ts @@ -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)) diff --git a/backend/api/src/validate-username.ts b/backend/api/src/validate-username.ts new file mode 100644 index 00000000..7b43a085 --- /dev/null +++ b/backend/api/src/validate-username.ts @@ -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 { + 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( + `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 +} diff --git a/backend/api/tests/unit/create-user-and-profile.unit.test.ts b/backend/api/tests/unit/create-user-and-profile.unit.test.ts new file mode 100644 index 00000000..c4f738c4 --- /dev/null +++ b/backend/api/tests/unit/create-user-and-profile.unit.test.ts @@ -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) + }) + }) +}) diff --git a/backend/api/tests/unit/update-me.unit.test.ts b/backend/api/tests/unit/update-me.unit.test.ts index 579495d7..7d2828e6 100644 --- a/backend/api/tests/unit/update-me.unit.test.ts +++ b/backend/api/tests/unit/update-me.unit.test.ts @@ -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') }) diff --git a/backend/shared/src/helpers/generate-and-update-avatar-urls.ts b/backend/shared/src/helpers/generate-and-update-avatar-urls.ts index 6f6fe6d2..3bd55a39 100644 --- a/backend/shared/src/helpers/generate-and-update-avatar-urls.ts +++ b/backend/shared/src/helpers/generate-and-update-avatar-urls.ts @@ -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}` } diff --git a/backend/shared/src/websockets/server.ts b/backend/shared/src/websockets/server.ts index 6c19cef7..b3ef8962 100644 --- a/backend/shared/src/websockets/server.ts +++ b/backend/shared/src/websockets/server.ts @@ -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) { 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) => { diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 01ec6cc0..9579f956 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -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 diff --git a/common/src/api/zod-types.ts b/common/src/api/zod-types.ts index c1efba5d..960fa1f7 100644 --- a/common/src/api/zod-types.ts +++ b/common/src/api/zod-types.ts @@ -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(), diff --git a/common/src/envs/constants.ts b/common/src/envs/constants.ts index cf4b5f5d..df0a81ad 100644 --- a/common/src/envs/constants.ts +++ b/common/src/envs/constants.ts @@ -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 diff --git a/common/src/profiles/og-image.ts b/common/src/profiles/og-image.ts index f653c015..da1dd3d0 100644 --- a/common/src/profiles/og-image.ts +++ b/common/src/profiles/og-image.ts @@ -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 ?? '', diff --git a/common/src/user.ts b/common/src/user.ts index 52559936..1ca3edc2 100644 --- a/common/src/user.ts +++ b/common/src/user.ts @@ -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 diff --git a/common/src/util/array.ts b/common/src/util/array.ts index ae407e5f..b3d3041a 100644 --- a/common/src/util/array.ts +++ b/common/src/util/array.ts @@ -47,20 +47,3 @@ export function fallbackIfEmpty(array: T[], fallback: any) { if (!Array.isArray(array)) return fallback return array.length > 0 ? array : fallback } - -export function nullifyDictValues(array: Record) { - // Nullify all the values of the dict - return Object.entries(array).reduce((acc, [key, _]) => { - return {...acc, [key]: null} - }, {}) -} - -export function sampleDictByPrefix(array: Record, 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 - }, {}) -} diff --git a/common/src/util/object.ts b/common/src/util/object.ts index ec1dad96..539c7d3a 100644 --- a/common/src/util/object.ts +++ b/common/src/util/object.ts @@ -74,3 +74,19 @@ export const hasSignificantDeepChanges = ( return false } +export function nullifyDictValues(array: Record) { + // Nullify all the values of the dict + return Object.entries(array).reduce((acc, [key, _]) => { + return {...acc, [key]: null} + }, {}) +} + +export function sampleDictByPrefix(array: Record, 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 + }, {}) +} diff --git a/package.json b/package.json index 509987de..2c39c92d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/e2e/utils/firebaseUtils.ts b/tests/e2e/utils/firebaseUtils.ts index 91e303e7..87a847f7 100644 --- a/tests/e2e/utils/firebaseUtils.ts +++ b/tests/e2e/utils/firebaseUtils.ts @@ -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) { diff --git a/tests/e2e/utils/seed-test-data.ts b/tests/e2e/utils/seed-test-data.ts index 82b5c283..b6286eae 100644 --- a/tests/e2e/utils/seed-test-data.ts +++ b/tests/e2e/utils/seed-test-data.ts @@ -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) diff --git a/tests/e2e/utils/seedDatabase.ts b/tests/e2e/utils/seedDatabase.ts index 5ac98dd6..0d931936 100644 --- a/tests/e2e/utils/seedDatabase.ts +++ b/tests/e2e/utils/seedDatabase.ts @@ -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) +} diff --git a/tests/e2e/utils/userCreation.ts b/tests/e2e/utils/userCreation.ts deleted file mode 100644 index 845f484d..00000000 --- a/tests/e2e/utils/userCreation.ts +++ /dev/null @@ -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) -})() diff --git a/tests/e2e/web/fixtures/signInFixture.ts b/tests/e2e/web/fixtures/signInFixture.ts index 7ea6b6db..42912fc7 100644 --- a/tests/e2e/web/fixtures/signInFixture.ts +++ b/tests/e2e/web/fixtures/signInFixture.ts @@ -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) } diff --git a/tests/e2e/web/utils/deleteUser.ts b/tests/e2e/web/utils/deleteUser.ts index 3446f531..eca1da27 100644 --- a/tests/e2e/web/utils/deleteUser.ts +++ b/tests/e2e/web/utils/deleteUser.ts @@ -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) { diff --git a/web/README.md b/web/README.md index 3fa82f20..3e7d90a3 100644 --- a/web/README.md +++ b/web/README.md @@ -220,7 +220,6 @@ Catches React errors and shows user-friendly message: ```tsx import {ErrorBoundary} from 'web/components/error-boundary' - ; @@ -250,7 +249,6 @@ Keyboard users can skip to main content: ```tsx import {SkipLink, MainContent} from 'web/components/skip-link' - ;<> ... diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index 9bb01b52..8d2253b8 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -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(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) => { diff --git a/web/components/filters/filters.tsx b/web/components/filters/filters.tsx index 2987c871..86a6de23 100644 --- a/web/components/filters/filters.tsx +++ b/web/components/filters/filters.tsx @@ -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, diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 09690afa..70675ada 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -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')} diff --git a/web/components/optional-profile-form.tsx b/web/components/optional-profile-form.tsx index c462d104..017a282d 100644 --- a/web/components/optional-profile-form.tsx +++ b/web/components/optional-profile-form.tsx @@ -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: (key: K, value: ProfileWithoutUser[K]) => void - user: User + user: BaseUser + setUser: (key: K, value: BaseUser[K]) => void buttonLabel?: string bottomNavBarVisible?: boolean - fromSignup?: boolean - onSubmit?: () => Promise + onSubmit: () => Promise }) => { 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(null) - const router = useRouter() const t = useT() const [heightFeet, setHeightFeet] = useState( 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>(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(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[] = [ - 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: { {/**/}
- {Object.entries(newLinks) + {Object.entries(user.link) .filter(([_, value]) => value != null) .map(([platform, value]) => ( @@ -1084,7 +998,7 @@ export const OptionalProfileUserForm = (props: { {/*
*/} setProfile('photo_urls', urls)} diff --git a/web/components/required-profile-form.tsx b/web/components/required-profile-form.tsx index ee048e95..2f90092b 100644 --- a/web/components/required-profile-form.tsx +++ b/web/components/required-profile-form.tsx @@ -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: ( + data: RequiredFormData + setData: ( 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(0) + const [loadingUsername, setLoadingUsername] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [errorUsername, setErrorUsername] = useState('') 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.')}*/} {/* */} {/*)}*/} - {isLocked && ( -
- {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.', - )} -
- )} {(step === 0 || profileCreatedAlready) && ( @@ -104,18 +92,15 @@ export const RequiredProfileUserForm = (props: { ) => { - updateUserState({name: e.target.value || ''}) + setData('name', e.target.value || '') }} - onBlur={updateDisplayName} /> - {loadingName && } - {errorName && {errorName}} )} @@ -128,15 +113,12 @@ export const RequiredProfileUserForm = (props: { ) => { - updateUserState({ - username: e.target.value || '', - errorUsername: '', - }) + setData('username', e.target.value || '') }} /> {loadingUsername && } @@ -172,9 +154,10 @@ export const RequiredProfileUserForm = (props: { {onSubmit && (