Move avatar URL and is-banned to separate columns and social links to profiles table

This commit is contained in:
MartinBraquet
2026-03-06 23:51:49 +01:00
parent 4b58e72607
commit 0655266366
42 changed files with 319 additions and 1348 deletions

View File

@@ -11,7 +11,7 @@ android {
applicationId "com.compassconnections.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 56
versionCode 57
versionName "1.11.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {

View File

@@ -1,6 +1,6 @@
{
"name": "@compass/api",
"version": "1.22.2",
"version": "1.23.0",
"private": true,
"description": "Backend API endpoints",
"main": "src/serve.ts",

View File

@@ -92,7 +92,7 @@ import {updateEvent} from './update-event'
import {updateMe} from './update-me'
import {updateNotifSettings} from './update-notif-setting'
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
import {updateProfile} from './update-profile'
import {updateProfileEndpoint} from './update-profile'
import {updateUserLocale} from './update-user-locale'
import {validateUsernameEndpoint} from './validate-username'
@@ -618,7 +618,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
'update-options': updateOptions,
'update-user-locale': updateUserLocale,
'update-private-user-message-channel': updatePrivateUserMessageChannel,
'update-profile': updateProfile,
'update-profile': updateProfileEndpoint,
'get-connection-interests': getConnectionInterestsEndpoint,
'update-connection-interest': updateConnectionInterests,
'user/by-id/:id': getUser,

View File

@@ -2,20 +2,16 @@ import {APIErrors, APIHandler} from 'api/helpers/endpoint'
import {isAdminId} from 'common/envs/constants'
import {trackPublicEvent} from 'shared/analytics'
import {throwErrorIfNotMod} from 'shared/helpers/auth'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {updateUser} from 'shared/supabase/users'
import {log} from 'shared/utils'
export const banUser: APIHandler<'ban-user'> = async (body, auth) => {
const {userId, unban} = body
const db = createSupabaseDirectClient()
await throwErrorIfNotMod(auth.uid)
if (isAdminId(userId)) throw APIErrors.forbidden('Cannot ban admin')
await trackPublicEvent(auth.uid, 'ban user', {
userId,
})
await updateUser(db, userId, {
isBannedFromPosting: !unban,
})
await updateUser(userId, {isBannedFromPosting: !unban})
log('updated user')
}

View File

@@ -8,7 +8,7 @@ import {tryCatch} from 'common/util/try-catch'
import {track} from 'shared/analytics'
import {removePinnedUrlFromPhotoUrls} from 'shared/profiles/parse-photos'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {updateUser} from 'shared/supabase/users'
import {updateUserData} from 'shared/supabase/users'
import {insert} from 'shared/supabase/utils'
import {getUser, log} from 'shared/utils'
@@ -29,7 +29,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
if (!user) throw APIErrors.unauthorized('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 || undefined})
updateUserData(pg, auth.uid, {avatarUrl: body.pinned_url || undefined})
}
debug('body', body)

View File

@@ -35,7 +35,6 @@ export const createUserAndProfile: APIHandler<'create-user-and-profile'> = async
locale = defaultLocale,
username,
name,
link,
profile,
interests,
causes,
@@ -55,7 +54,7 @@ export const createUserAndProfile: APIHandler<'create-user-and-profile'> = async
const email = fbUser.email
const bucket = getBucket()
const avatarUrl = await generateAvatarUrl(auth.uid, cleanName, bucket)
const avatarUrl = profile.pinned_url ?? (await generateAvatarUrl(auth.uid, cleanName, bucket))
let finalUsername = username
const validation = await validateUsername(username)
@@ -88,15 +87,6 @@ export const createUserAndProfile: APIHandler<'create-user-and-profile'> = async
})
}
const userData = removeUndefinedProps({
avatarUrl,
isBannedFromPosting: Boolean(
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
(ip && bannedIpAddresses.includes(ip)),
),
link: link,
})
const privateUserData: PrivateUser = {
id: auth.uid,
email,
@@ -112,7 +102,12 @@ export const createUserAndProfile: APIHandler<'create-user-and-profile'> = async
id: auth.uid,
name: cleanName,
username: finalUsername,
data: userData,
avatar_url: avatarUrl,
is_banned_from_posting: Boolean(
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
(ip && bannedIpAddresses.includes(ip)),
),
data: {},
})
const newPrivateUserRow = await insert(tx, 'private_users', {

View File

@@ -76,7 +76,7 @@ export const createUser: APIHandler<'create-user'> = async (props, auth, req) =>
const {user, privateUser} = await pg.tx(async (tx) => {
const preexistingUser = await getUser(auth.uid, tx)
if (preexistingUser)
throw APIErrors.forbidden('User already exists', {
throw APIErrors.forbidden('An account for this user already exists', {
field: 'userId',
context: `User with ID ${auth.uid} already exists`,
})
@@ -84,14 +84,14 @@ export const createUser: APIHandler<'create-user'> = async (props, auth, req) =>
// Check exact username to avoid problems with duplicate requests
const sameNameUser = await getUserByUsername(username, tx)
if (sameNameUser)
throw APIErrors.conflict('Username already taken', {
throw APIErrors.conflict('Username is already taken', {
field: 'username',
context: `Username "${username}" is already taken`,
})
const user = removeUndefinedProps({
avatarUrl,
isBannedFromPosting: Boolean(
is_banned_from_posting: Boolean(
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
(ip && bannedIpAddresses.includes(ip)),
),

View File

@@ -52,7 +52,7 @@ export const getEvents: APIHandler<'get-events'> = async () => {
username: string
avatar_url: string | null
}>(
`SELECT id, name, username, data ->> 'avatarUrl' as avatar_url
`SELECT id, name, username, avatar_url
FROM users
WHERE id = ANY ($1)`,
[creatorIds],

View File

@@ -25,7 +25,7 @@ export const getLikesAndShipsMain = async (userId: string) => {
where creator_id = $1
and looking_for_matches
and profiles.pinned_url is not null
and (data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)
and not is_banned_from_posting
order by created_time desc
`,
[userId],
@@ -47,7 +47,7 @@ export const getLikesAndShipsMain = async (userId: string) => {
where target_id = $1
and looking_for_matches
and profiles.pinned_url is not null
and (data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)
and not is_banned_from_posting
order by created_time desc
`,
[userId],
@@ -74,7 +74,7 @@ export const getLikesAndShipsMain = async (userId: string) => {
where target2_id = $1
and profiles.looking_for_matches
and profiles.pinned_url is not null
and (users.data->>'isBannedFromPosting' != 'true' or users.data->>'isBannedFromPosting' is null)
and not is_banned_from_posting
union all
@@ -87,7 +87,7 @@ export const getLikesAndShipsMain = async (userId: string) => {
where target1_id = $1
and profiles.looking_for_matches
and profiles.pinned_url is not null
and (users.data->>'isBannedFromPosting' != 'true' or users.data->>'isBannedFromPosting' is null)
and not is_banned_from_posting
`,
[userId],
(r) => ({

View File

@@ -239,9 +239,8 @@ export const loadProfiles = async (props: profileQueryType) => {
const filters = [
where('looking_for_matches = true'),
where(`profiles.disabled != true`),
// where(`pinned_url is not null and pinned_url != ''`),
where(`(data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)`),
where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
where(`not users.is_banned_from_posting`),
// where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
...keywords.map((word) =>
where(

View File

@@ -1,12 +1,11 @@
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'
import {cloneDeep, mapValues} from 'lodash'
import {cloneDeep} from 'lodash'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {updateUser} from 'shared/supabase/users'
import {updateUser, updateUserData} from 'shared/supabase/users'
import {getUser, getUserByUsername} from 'shared/utils'
import {broadcastUpdatedUser} from 'shared/websockets/helpers'
@@ -29,7 +28,7 @@ export const updateMe: APIHandler<'me/update'> = async (props, auth) => {
if (reservedName) throw APIErrors.forbidden('This username is reserved')
const otherUserExists = await getUserByUsername(cleanedUsername)
if (otherUserExists && otherUserExists.id !== auth.uid)
throw APIErrors.conflict('Username already taken')
throw APIErrors.conflict('Username is already taken')
update.username = cleanedUsername
}
@@ -37,69 +36,23 @@ export const updateMe: APIHandler<'me/update'> = async (props, auth) => {
debug({update})
const {name, username, avatarUrl, link = {}, ...rest} = update
await updateUser(pg, auth.uid, removeUndefinedProps(rest))
const stripped = mapValues(link, (value, site) => value && strip(site as any, value))
const adds = {} as {[key: string]: string}
const removes = []
for (const [key, value] of Object.entries(stripped)) {
if (value === null || value === '') {
removes.push(key)
} else if (value) {
adds[key] = value
}
}
let newLinks: any = null
if (Object.keys(adds).length > 0 || removes.length > 0) {
const data = await pg.oneOrNone(
`update users
set data = jsonb_set(
data, '{link}',
(data -> 'link' || $(adds)) - $(removes)
)
where id = $(id)
returning data -> 'link' as link`,
{adds, removes, id: auth.uid},
)
newLinks = data?.link
}
if (name) {
await pg.none(
`update users
set name = $1
where id = $2`,
[name, auth.uid],
)
}
if (username) {
await pg.none(
`update users
set username = $1
where id = $2`,
[username, auth.uid],
)
}
if (avatarUrl) {
await updateUser(pg, auth.uid, {avatarUrl})
}
const {name, username, avatarUrl, ...rest} = update
await updateUserData(pg, auth.uid, removeUndefinedProps(rest))
// Ensure clients listening on `user/{id}` (e.g. AuthContext via useWebsocketUser)
// get notified about link-only changes as well.
if (name || username || avatarUrl || newLinks != null) {
if (name || username || avatarUrl) {
await updateUser(auth.uid, {name, username, avatarUrl})
broadcastUpdatedUser(
removeUndefinedProps({
id: auth.uid,
name,
username,
avatarUrl,
link: newLinks ?? undefined,
}),
)
}
return toUserAPIResponse({...user, ...update, link: newLinks})
return toUserAPIResponse({...user, ...update})
}

View File

@@ -4,8 +4,7 @@ import {type Row} from 'common/supabase/utils'
import {tryCatch} from 'common/util/try-catch'
import {removePinnedUrlFromPhotoUrls} from 'shared/profiles/parse-photos'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {updateUser} from 'shared/supabase/users'
import {update} from 'shared/supabase/utils'
import {updateProfile, updateUser} from 'shared/supabase/users'
import {log} from 'shared/utils'
/**
@@ -25,7 +24,7 @@ import {log} from 'shared/utils'
* @returns Updated profile data
* @throws {APIError} 404 if profile doesn't exist, 500 for database errors
*/
export const updateProfile: APIHandler<'update-profile'> = async (parsedBody, auth) => {
export const updateProfileEndpoint: APIHandler<'update-profile'> = async (parsedBody, auth) => {
trimStrings(parsedBody)
log('Updating profile', parsedBody)
const pg = createSupabaseDirectClient()
@@ -43,12 +42,10 @@ export const updateProfile: APIHandler<'update-profile'> = async (parsedBody, au
await removePinnedUrlFromPhotoUrls(parsedBody)
if (parsedBody.pinned_url) {
await updateUser(pg, auth.uid, {avatarUrl: parsedBody.pinned_url})
await updateUser(auth.uid, {avatarUrl: parsedBody.pinned_url})
}
const {data, error} = await tryCatch(
update(pg, 'profiles', 'user_id', {user_id: auth.uid, ...parsedBody}),
)
const {data, error} = await tryCatch(updateProfile(auth.uid, parsedBody))
if (error) {
log('Error updating profile', error)

View File

@@ -45,9 +45,9 @@ describe('banUser', () => {
expect(sharedAnalytics.trackPublicEvent).toBeCalledWith(mockAuth.uid, 'ban user', {
userId: mockUser.userId,
})
expect(supabaseUsers.updateUser).toBeCalledTimes(1)
expect(supabaseUsers.updateUser).toBeCalledWith(mockPg, mockUser.userId, {
isBannedFromPosting: true,
expect(supabaseUsers.updateUserData).toBeCalledTimes(1)
expect(supabaseUsers.updateUserData).toBeCalledWith(mockPg, mockUser.userId, {
is_banned_from_posting: true,
})
})
@@ -68,8 +68,8 @@ describe('banUser', () => {
expect(sharedAnalytics.trackPublicEvent).toBeCalledWith(mockAuth.uid, 'ban user', {
userId: mockUser.userId,
})
expect(supabaseUsers.updateUser).toBeCalledWith(mockPg, mockUser.userId, {
isBannedFromPosting: false,
expect(supabaseUsers.updateUserData).toBeCalledWith(mockPg, mockUser.userId, {
is_banned_from_posting: false,
})
})
})

View File

@@ -44,7 +44,7 @@ describe('createComment', () => {
name: 'Mock Creator',
username: 'mock.creator.username',
avatarUrl: 'mock.creator.avatarurl',
isBannedFromPosting: false,
is_banned_from_posting: false,
}
const mockContent = {
content: {
@@ -143,7 +143,7 @@ describe('createComment', () => {
name: 'Mock Creator',
username: 'mock.creator.username',
avatarUrl: 'mock.creator.avatarurl',
isBannedFromPosting: false,
is_banned_from_posting: false,
}
const mockContent = {
content: {
@@ -227,7 +227,7 @@ describe('createComment', () => {
name: 'Mock Creator',
username: 'mock.creator.username',
avatarUrl: 'mock.creator.avatarurl',
isBannedFromPosting: true,
is_banned_from_posting: true,
}
const mockContent = {
content: {
@@ -270,7 +270,7 @@ describe('createComment', () => {
name: 'Mock Creator',
username: 'mock.creator.username',
avatarUrl: 'mock.creator.avatarurl',
isBannedFromPosting: false,
is_banned_from_posting: false,
}
const mockContent = {
content: {
@@ -316,7 +316,7 @@ describe('createComment', () => {
name: 'Mock Creator',
username: 'mock.creator.username',
avatarUrl: 'mock.creator.avatarurl',
isBannedFromPosting: false,
is_banned_from_posting: false,
}
const mockContent = {
content: {
@@ -362,7 +362,7 @@ describe('createComment', () => {
name: 'Mock Creator',
username: 'mock.creator.username',
avatarUrl: 'mock.creator.avatarurl',
isBannedFromPosting: false,
is_banned_from_posting: false,
}
const mockContent = {
content: {

View File

@@ -58,7 +58,7 @@ describe('createPrivateUserMessageChannel', () => {
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
const mockCreator = {
isBannedFromPosting: false,
is_banned_from_posting: false,
}
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(mockCreator)
@@ -104,7 +104,7 @@ describe('createPrivateUserMessageChannel', () => {
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
const mockCreator = {
isBannedFromPosting: false,
is_banned_from_posting: false,
}
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(mockCreator)
@@ -183,7 +183,7 @@ describe('createPrivateUserMessageChannel', () => {
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
const mockCreator = {
isBannedFromPosting: true,
is_banned_from_posting: true,
}
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(mockCreator)
@@ -207,7 +207,7 @@ describe('createPrivateUserMessageChannel', () => {
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
const mockCreator = {
isBannedFromPosting: false,
is_banned_from_posting: false,
}
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(mockCreator)
@@ -237,7 +237,7 @@ describe('createPrivateUserMessageChannel', () => {
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
const mockCreator = {
isBannedFromPosting: false,
is_banned_from_posting: false,
}
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(mockCreator)

View File

@@ -31,7 +31,7 @@ describe('createPrivateUserMessage', () => {
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
const mockCreator = {
isBannedFromPosting: false,
is_banned_from_posting: false,
}
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(mockCreator)
@@ -87,7 +87,7 @@ describe('createPrivateUserMessage', () => {
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
const mockCreator = {
isBannedFromPosting: true,
is_banned_from_posting: true,
}
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(mockCreator)

View File

@@ -1,370 +0,0 @@
jest.mock('shared/supabase/init')
jest.mock('shared/utils')
jest.mock('shared/profiles/parse-photos')
jest.mock('shared/supabase/users')
jest.mock('shared/supabase/utils')
jest.mock('common/util/try-catch')
jest.mock('shared/analytics')
jest.mock('common/discord/core')
jest.mock('common/util/time')
import {createProfile} from 'api/create-profile'
import {AuthedUser} from 'api/helpers/endpoint'
import {sendDiscordMessage} from 'common/discord/core'
import {sqlMatch} from 'common/test-utils'
import {tryCatch} from 'common/util/try-catch'
import * as sharedAnalytics from 'shared/analytics'
import {removePinnedUrlFromPhotoUrls} from 'shared/profiles/parse-photos'
import * as supabaseInit from 'shared/supabase/init'
import * as supabaseUsers from 'shared/supabase/users'
import * as supabaseUtils from 'shared/supabase/utils'
import * as sharedUtils from 'shared/utils'
describe('createProfile', () => {
let mockPg = {} as any
beforeEach(() => {
jest.resetAllMocks()
mockPg = {
oneOrNone: jest.fn(),
one: jest.fn(),
}
;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('when given valid input', () => {
it('should successfully create a profile', async () => {
const mockBody = {
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 mockReq = {} as any
const mockNProfiles = 10
const mockData = {
age: 30,
city: 'mockCity',
}
const mockUser = {
createdTime: Date.now(),
name: 'mockName',
username: 'mockUserName',
}
;(tryCatch as jest.Mock).mockResolvedValueOnce({data: false, error: null})
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(mockUser)
;(tryCatch as jest.Mock).mockResolvedValueOnce({data: mockData, error: null})
const results: any = await createProfile(mockBody, mockAuth, mockReq)
expect(results.result).toEqual(mockData)
expect(tryCatch).toBeCalledTimes(2)
expect(mockPg.oneOrNone).toBeCalledTimes(1)
expect(mockPg.oneOrNone).toBeCalledWith(
sqlMatch('select id from profiles where user_id = $1'),
[mockAuth.uid],
)
expect(removePinnedUrlFromPhotoUrls).toBeCalledTimes(1)
expect(removePinnedUrlFromPhotoUrls).toBeCalledWith(mockBody)
expect(sharedUtils.getUser).toBeCalledTimes(1)
expect(sharedUtils.getUser).toBeCalledWith(mockAuth.uid)
expect(supabaseUsers.updateUser).toBeCalledTimes(1)
expect(supabaseUsers.updateUser).toBeCalledWith(expect.any(Object), mockAuth.uid, {
avatarUrl: mockBody.pinned_url,
})
expect(supabaseUtils.insert).toBeCalledTimes(1)
expect(supabaseUtils.insert).toBeCalledWith(
expect.any(Object),
'profiles',
expect.objectContaining({user_id: mockAuth.uid}),
)
;(mockPg.one as jest.Mock).mockReturnValue(mockNProfiles)
await results.continue()
expect(sharedAnalytics.track).toBeCalledTimes(1)
expect(sharedAnalytics.track).toBeCalledWith(mockAuth.uid, 'create profile', {
username: mockUser.username,
})
expect(sendDiscordMessage).toBeCalledTimes(1)
expect(sendDiscordMessage).toBeCalledWith(
expect.stringContaining(mockUser.name && mockUser.username),
'members',
)
})
it('should successfully create milestone profile', async () => {
const mockBody = {
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 mockReq = {} as any
const mockNProfiles = 15
const mockData = {
age: 30,
city: 'mockCity',
}
const mockUser = {
createdTime: Date.now() - 2 * 60 * 60 * 1000, //2 hours ago
name: 'mockName',
username: 'mockUserName',
}
;(tryCatch as jest.Mock).mockResolvedValueOnce({data: false, error: null})
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(mockUser)
;(tryCatch as jest.Mock).mockResolvedValueOnce({data: mockData, error: null})
const results: any = await createProfile(mockBody, mockAuth, mockReq)
expect(results.result).toEqual(mockData)
;(mockPg.one as jest.Mock).mockReturnValue(mockNProfiles)
await results.continue()
expect(mockPg.one).toBeCalledTimes(1)
expect(mockPg.one).toBeCalledWith(
sqlMatch('SELECT count(*) FROM profiles'),
[],
expect.any(Function),
)
expect(sendDiscordMessage).toBeCalledTimes(2)
expect(sendDiscordMessage).toHaveBeenNthCalledWith(
2,
expect.stringContaining(String(mockNProfiles)),
'general',
)
})
})
describe('when an error occurs', () => {
it('should throw if it failed to track create profile', async () => {
const mockBody = {
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 mockReq = {} as any
const mockData = {
age: 30,
city: 'mockCity',
}
const mockUser = {
createdTime: Date.now() - 2 * 60 * 60 * 1000, //2 hours ago
name: 'mockName',
username: 'mockUserName',
}
;(tryCatch as jest.Mock).mockResolvedValueOnce({data: false, error: null})
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(mockUser)
;(tryCatch as jest.Mock).mockResolvedValueOnce({data: mockData, error: null})
const results: any = await createProfile(mockBody, mockAuth, mockReq)
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
;(sharedAnalytics.track as jest.Mock).mockRejectedValue(new Error('Track error'))
await results.continue()
expect(errorSpy).toBeCalledWith(
'Failed to track create profile',
expect.objectContaining({name: 'Error'}),
)
})
it('should throw if it failed to send discord new profile', async () => {
const mockBody = {
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 mockReq = {} as any
const mockData = {
age: 30,
city: 'mockCity',
}
const mockUser = {
createdTime: Date.now() - 2 * 60 * 60 * 1000, //2 hours ago
name: 'mockName',
username: 'mockUserName',
}
;(tryCatch as jest.Mock).mockResolvedValueOnce({data: null, error: null})
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(mockUser)
;(tryCatch as jest.Mock).mockResolvedValueOnce({data: mockData, error: null})
const results: any = await createProfile(mockBody, mockAuth, mockReq)
expect(results.result).toEqual(mockData)
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
;(sendDiscordMessage as jest.Mock).mockRejectedValue(new Error('Sending error'))
await results.continue()
expect(errorSpy).toBeCalledWith(
'Failed to send discord new profile',
expect.objectContaining({name: 'Error'}),
)
})
it('should throw if it failed to send discord user milestone', async () => {
const mockBody = {
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 mockReq = {} as any
const mockNProfiles = 15
const mockData = {
age: 30,
city: 'mockCity',
}
const mockUser = {
createdTime: Date.now() - 2 * 60 * 60 * 1000, //2 hours ago
name: 'mockName',
username: 'mockUserName',
}
;(tryCatch as jest.Mock).mockResolvedValueOnce({data: null, error: null})
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(mockUser)
;(tryCatch as jest.Mock).mockResolvedValueOnce({data: mockData, error: null})
const results: any = await createProfile(mockBody, mockAuth, mockReq)
expect(results.result).toEqual(mockData)
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
;(sendDiscordMessage as jest.Mock)
.mockResolvedValueOnce(null)
.mockRejectedValueOnce(new Error('Discord error'))
;(mockPg.one as jest.Mock).mockReturnValue(mockNProfiles)
await results.continue()
expect(sendDiscordMessage).toBeCalledTimes(2)
expect(sendDiscordMessage).toHaveBeenNthCalledWith(
2,
expect.stringContaining(String(mockNProfiles)),
'general',
)
expect(errorSpy).toBeCalledWith(
'Failed to send discord user milestone',
expect.objectContaining({name: 'Error'}),
)
})
it('should throw if the profile already exists', async () => {
const mockBody = {
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 mockReq = {} as any
;(tryCatch as jest.Mock).mockResolvedValueOnce({data: true, error: null})
await expect(createProfile(mockBody, mockAuth, mockReq)).rejects.toThrowError(
'Profile already exists',
)
})
it('should throw if unable to find the account', async () => {
const mockBody = {
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 mockReq = {} as any
;(tryCatch as jest.Mock).mockResolvedValueOnce({data: null, error: null})
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(false)
await expect(createProfile(mockBody, mockAuth, mockReq)).rejects.toThrowError(
'Your account was not found',
)
})
it('should throw if anything unexpected happens when creating the user', async () => {
const mockBody = {
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 mockReq = {} as any
const mockUser = {
createdTime: Date.now() - 2 * 60 * 60 * 1000, //2 hours ago
name: 'mockName',
username: 'mockUserName',
}
;(tryCatch as jest.Mock).mockResolvedValueOnce({data: null, error: null})
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(mockUser)
;(tryCatch as jest.Mock).mockResolvedValueOnce({data: null, error: Error})
await expect(createProfile(mockBody, mockAuth, mockReq)).rejects.toThrowError(
'Error creating user',
)
})
})
})

View File

@@ -161,7 +161,7 @@ describe('createUserAndProfile', () => {
expect(usernameUtils.cleanDisplayName).toBeCalledTimes(1)
expect(usernameUtils.cleanDisplayName).toHaveBeenCalledWith(mockProps.name)
expect(firebaseUtils.getBucket).toBeCalledTimes(1)
expect(avatarHelpers.generateAvatarUrl).toBeCalledTimes(1)
expect(avatarHelpers.generateAvatarUrl).toBeCalledTimes(0)
expect(validateUsernameModule.validateUsername).toBeCalledTimes(1)
expect(validateUsernameModule.validateUsername).toHaveBeenCalledWith(mockProps.username)
expect(parsePhotos.removePinnedUrlFromPhotoUrls).toBeCalledTimes(1)
@@ -318,11 +318,11 @@ describe('createUserAndProfile', () => {
const mockAvatarUrl = 'mockGeneratedAvatarUrl'
const mockNewUserRow = {
created_time: 'mockCreatedTime',
data: {isBannedFromPosting: true},
id: 'mockNewUserId',
name: 'mockName',
name_username_vector: 'mockNameUsernameVector',
username: 'mockUsername',
is_banned_from_posting: true,
}
const mockPrivateUserRow = {
data: {},
@@ -357,9 +357,11 @@ describe('createUserAndProfile', () => {
await createUserAndProfile(mockProps, mockAuth, mockReq)
expect(objectUtils.removeUndefinedProps).toHaveBeenCalledWith(
expect(supabaseUtils.insert).toHaveBeenCalledWith(
expect.any(Object),
'users',
expect.objectContaining({
isBannedFromPosting: true,
is_banned_from_posting: true,
}),
)
})
@@ -405,7 +407,7 @@ describe('createUserAndProfile', () => {
})
await expect(createUserAndProfile(mockProps, mockAuth, mockReq)).rejects.toThrowError(
'User already exists',
'An account for this user already exists',
)
})
@@ -450,7 +452,7 @@ describe('createUserAndProfile', () => {
})
await expect(createUserAndProfile(mockProps, mockAuth, mockReq)).rejects.toThrowError(
'Username already taken',
'Username is already taken',
)
})

View File

@@ -1,719 +0,0 @@
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(),
}))
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')
import {createUser} from 'api/create-user'
import {AuthedUser} from 'api/helpers/endpoint'
import * as apiSetLastTimeOnline from 'api/set-last-online-time'
import * as hostingConstants from 'common/hosting/constants'
import * as supabaseUsers from 'common/supabase/users'
import * as userNotificationPref from 'common/user-notification-preferences'
import * as usernameUtils from 'common/util/clean-username'
import * as objectUtils from 'common/util/object'
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 supabaseInit from 'shared/supabase/init'
import * as supabaseUtils from 'shared/supabase/utils'
import * as sharedUtils from 'shared/utils'
describe('createUser', () => {
const originalIsLocal = (hostingConstants as any).IS_LOCAL
let mockPg = {} as any
beforeEach(() => {
jest.resetAllMocks()
mockPg = {
one: jest.fn(),
tx: jest.fn(async (cb) => {
const mockTx = {} as any
return cb(mockTx)
}),
}
;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
})
afterEach(() => {
jest.restoreAllMocks()
Object.defineProperty(hostingConstants, 'IS_LOCAL', {
value: originalIsLocal,
writable: true,
})
})
describe('when given valid input', () => {
it('should successfully create a user', async () => {
Object.defineProperty(hostingConstants, 'IS_LOCAL', {
value: false,
writable: true,
})
const mockProps = {
deviceToken: 'mockDeviceToken',
adminToken: 'mockAdminToken',
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockFbUser = {
email: 'mockEmail@mockServer.com',
displayName: 'mockDisplayName',
photoURL: 'mockPhotoUrl',
}
const mockIp = 'mockIP'
const mockBucket = {} as any
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',
}
const mockGetUser = jest.fn().mockResolvedValueOnce(mockFbUser)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(usernameUtils.cleanUsername as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(mockPg.one as jest.Mock).mockResolvedValue(0)
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(false)
;(sharedUtils.getUserByUsername as jest.Mock).mockResolvedValue(false)
;(userNotificationPref.getDefaultNotificationPreferences as jest.Mock).mockReturnValue(null)
;(supabaseUtils.insert as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(null)
;(supabaseUsers.convertUser as jest.Mock).mockReturnValue(mockNewUserRow)
;(supabaseUsers.convertPrivateUser as jest.Mock).mockReturnValue(mockPrivateUserRow)
const results: any = await createUser(mockProps, mockAuth, mockReq)
expect(results.result.user).toEqual(mockNewUserRow)
expect(results.result.privateUser).toEqual(mockPrivateUserRow)
expect(mockGetUser).toBeCalledTimes(1)
expect(mockGetUser).toHaveBeenNthCalledWith(1, mockAuth.uid)
expect(usernameUtils.cleanDisplayName).toBeCalledTimes(1)
expect(usernameUtils.cleanDisplayName).toHaveBeenCalledWith(mockFbUser.displayName)
expect(usernameUtils.cleanUsername).toBeCalledTimes(1)
expect(usernameUtils.cleanUsername).toBeCalledWith(mockFbUser.displayName)
expect(mockPg.one).toBeCalledTimes(1)
expect(mockPg.tx).toBeCalledTimes(1)
expect(sharedUtils.getUser).toBeCalledTimes(1)
expect(sharedUtils.getUser).toHaveBeenCalledWith(mockAuth.uid, expect.any(Object))
expect(userNotificationPref.getDefaultNotificationPreferences).toBeCalledTimes(1)
expect(supabaseUtils.insert).toBeCalledTimes(2)
expect(supabaseUtils.insert).toHaveBeenNthCalledWith(
1,
expect.any(Object),
'users',
expect.objectContaining({
id: mockAuth.uid,
name: mockFbUser.displayName,
username: mockFbUser.displayName,
}),
)
expect(supabaseUtils.insert).toHaveBeenNthCalledWith(
2,
expect.any(Object),
'private_users',
expect.objectContaining({
id: mockAuth.uid,
}),
)
;(sharedAnalytics.track as jest.Mock).mockResolvedValue(null)
;(emailHelpers.sendWelcomeEmail as jest.Mock).mockResolvedValue(null)
;(apiSetLastTimeOnline.setLastOnlineTimeUser as jest.Mock).mockResolvedValue(null)
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)
})
it('should generate a avatar Url when creating a user', async () => {
const mockProps = {
deviceToken: 'mockDeviceToken',
adminToken: 'mockAdminToken',
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockFirebaseUser = {
providerData: [
{
providerId: 'password',
},
],
}
const mockFbUser = {
email: 'mockEmail@mockServer.com',
displayName: 'mockDisplayName',
}
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',
}
const mockGetUser = jest
.fn()
.mockResolvedValueOnce(mockFirebaseUser)
.mockResolvedValueOnce(mockFbUser)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(avatarHelpers.generateAvatarUrl as jest.Mock).mockResolvedValue(mockAvatarUrl)
;(usernameUtils.cleanUsername as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(mockPg.one as jest.Mock).mockResolvedValue(0)
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(false)
;(sharedUtils.getUserByUsername as jest.Mock).mockResolvedValue(false)
;(userNotificationPref.getDefaultNotificationPreferences as jest.Mock).mockReturnValue(null)
;(supabaseUtils.insert as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(null)
;(supabaseUsers.convertUser as jest.Mock).mockReturnValue(mockNewUserRow)
;(supabaseUsers.convertPrivateUser as jest.Mock).mockReturnValue(mockPrivateUserRow)
await createUser(mockProps, mockAuth, mockReq)
expect(objectUtils.removeUndefinedProps).toHaveBeenCalledTimes(1)
expect(objectUtils.removeUndefinedProps).toHaveBeenCalledWith({
avatarUrl: mockAvatarUrl,
isBannedFromPosting: false,
link: expect.any(Object),
})
})
it('should not allow a username that already exists when creating a user', async () => {
const mockProps = {
deviceToken: 'mockDeviceToken',
adminToken: 'mockAdminToken',
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockFirebaseUser = {
providerData: [
{
providerId: 'passwords',
},
],
}
const mockFbUser = {
email: 'mockEmail@mockServer.com',
displayName: 'mockDisplayName',
photoURL: 'mockPhotoUrl',
}
const mockIp = 'mockIP'
const mockBucket = {} as any
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',
}
const mockGetUser = jest
.fn()
.mockResolvedValueOnce(mockFirebaseUser)
.mockResolvedValueOnce(mockFbUser)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(usernameUtils.cleanUsername as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(mockPg.one as jest.Mock).mockResolvedValue(1)
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(false)
;(sharedUtils.getUserByUsername as jest.Mock).mockResolvedValue(false)
;(userNotificationPref.getDefaultNotificationPreferences as jest.Mock).mockReturnValue(null)
;(supabaseUtils.insert as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(null)
;(supabaseUsers.convertUser as jest.Mock).mockReturnValue(mockNewUserRow)
;(supabaseUsers.convertPrivateUser as jest.Mock).mockReturnValue(mockPrivateUserRow)
await createUser(mockProps, mockAuth, mockReq)
expect(mockPg.one).toBeCalledTimes(1)
expect(supabaseUtils.insert).toBeCalledTimes(2)
expect(supabaseUtils.insert).not.toHaveBeenNthCalledWith(
1,
expect.any(Object),
'users',
expect.objectContaining({
id: mockAuth.uid,
name: mockFbUser.displayName,
username: mockFbUser.displayName,
}),
)
})
it('should successfully create a user who is banned from posting if there ip/device token is banned', async () => {
const mockProps = {
deviceToken: 'mockDeviceToken',
adminToken: 'mockAdminToken',
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockFirebaseUser = {
providerData: [
{
providerId: 'passwords',
},
],
}
const mockFbUser = {
email: 'mockEmail@mockServer.com',
displayName: 'mockDisplayName',
photoURL: 'mockPhotoUrl',
}
const mockIp = 'mockIP'
const mockBucket = {} as any
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',
}
const mockGetUser = jest
.fn()
.mockResolvedValueOnce(mockFirebaseUser)
.mockResolvedValueOnce(mockFbUser)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(usernameUtils.cleanUsername as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(avatarHelpers.generateAvatarUrl as jest.Mock).mockResolvedValue(mockFbUser.photoURL)
;(mockPg.one as jest.Mock).mockResolvedValue(0)
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(false)
;(sharedUtils.getUserByUsername as jest.Mock).mockResolvedValue(false)
jest.spyOn(Array.prototype, 'includes').mockReturnValue(true)
;(userNotificationPref.getDefaultNotificationPreferences as jest.Mock).mockReturnValue(null)
;(supabaseUtils.insert as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(null)
;(supabaseUsers.convertUser as jest.Mock).mockReturnValue(mockNewUserRow)
;(supabaseUsers.convertPrivateUser as jest.Mock).mockReturnValue(mockPrivateUserRow)
await createUser(mockProps, mockAuth, mockReq)
expect(objectUtils.removeUndefinedProps).toHaveBeenCalledTimes(1)
expect(objectUtils.removeUndefinedProps).toHaveBeenCalledWith({
avatarUrl: mockFbUser.photoURL,
isBannedFromPosting: true,
link: {},
})
})
})
describe('when an error occurs', () => {
it('should throw if the user already exists', async () => {
const mockProps = {
deviceToken: 'mockDeviceToken',
adminToken: 'mockAdminToken',
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockFirebaseUser = {
providerData: [
{
providerId: 'passwords',
},
],
}
const mockFbUser = {
email: 'mockEmail@mockServer.com',
displayName: 'mockDisplayName',
photoURL: 'mockPhotoUrl',
}
const mockIp = 'mockIP'
const mockBucket = {} as any
const mockGetUser = jest
.fn()
.mockResolvedValueOnce(mockFirebaseUser)
.mockResolvedValueOnce(mockFbUser)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(usernameUtils.cleanUsername as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(mockPg.one as jest.Mock).mockResolvedValue(0)
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(true)
expect(createUser(mockProps, mockAuth, mockReq)).rejects.toThrowError('User already exists')
})
it('should throw if the username is already taken', async () => {
const mockProps = {
deviceToken: 'mockDeviceToken',
adminToken: 'mockAdminToken',
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockFirebaseUser = {
providerData: [
{
providerId: 'passwords',
},
],
}
const mockFbUser = {
email: 'mockEmail@mockServer.com',
displayName: 'mockDisplayName',
photoURL: 'mockPhotoUrl',
}
const mockIp = 'mockIP'
const mockBucket = {} as any
const mockGetUser = jest
.fn()
.mockResolvedValueOnce(mockFirebaseUser)
.mockResolvedValueOnce(mockFbUser)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(usernameUtils.cleanUsername as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(mockPg.one as jest.Mock).mockResolvedValue(0)
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(false)
;(sharedUtils.getUserByUsername as jest.Mock).mockResolvedValue(true)
expect(createUser(mockProps, mockAuth, mockReq)).rejects.toThrowError(
'Username already taken',
)
})
it('should throw if failed to track create profile', async () => {
const mockProps = {
deviceToken: 'mockDeviceToken',
adminToken: 'mockAdminToken',
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockFirebaseUser = {
providerData: [
{
providerId: 'passwords',
},
],
}
const mockFbUser = {
email: 'mockEmail@mockServer.com',
displayName: 'mockDisplayName',
photoURL: 'mockPhotoUrl',
}
const mockIp = 'mockIP'
const mockBucket = {} as any
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',
}
const mockGetUser = jest
.fn()
.mockResolvedValueOnce(mockFirebaseUser)
.mockResolvedValueOnce(mockFbUser)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(usernameUtils.cleanUsername as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(mockPg.one as jest.Mock).mockResolvedValue(0)
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(false)
;(sharedUtils.getUserByUsername as jest.Mock).mockResolvedValue(false)
;(userNotificationPref.getDefaultNotificationPreferences as jest.Mock).mockReturnValue(null)
;(supabaseUtils.insert as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(null)
;(supabaseUsers.convertUser as jest.Mock).mockReturnValue(mockNewUserRow)
;(supabaseUsers.convertPrivateUser as jest.Mock).mockReturnValue(mockPrivateUserRow)
const results: any = await createUser(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 throw if failed to send a welcome email', async () => {
Object.defineProperty(hostingConstants, 'IS_LOCAL', {
value: false,
writable: true,
})
const mockProps = {
deviceToken: 'mockDeviceToken',
adminToken: 'mockAdminToken',
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockFirebaseUser = {
providerData: [
{
providerId: 'passwords',
},
],
}
const mockFbUser = {
email: 'mockEmail@mockServer.com',
displayName: 'mockDisplayName',
photoURL: 'mockPhotoUrl',
}
const mockIp = 'mockIP'
const mockBucket = {} as any
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',
}
const mockGetUser = jest
.fn()
.mockResolvedValueOnce(mockFirebaseUser)
.mockResolvedValueOnce(mockFbUser)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(usernameUtils.cleanUsername as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(mockPg.one as jest.Mock).mockResolvedValue(0)
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(false)
;(sharedUtils.getUserByUsername as jest.Mock).mockResolvedValue(false)
;(userNotificationPref.getDefaultNotificationPreferences as jest.Mock).mockReturnValue(null)
;(supabaseUtils.insert as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(null)
;(supabaseUsers.convertUser as jest.Mock).mockReturnValue(mockNewUserRow)
;(supabaseUsers.convertPrivateUser as jest.Mock).mockReturnValue(mockPrivateUserRow)
const results: any = await createUser(mockProps, mockAuth, mockReq)
;(sharedAnalytics.track as jest.Mock).mockResolvedValue(null)
;(emailHelpers.sendWelcomeEmail as jest.Mock).mockRejectedValue(
new Error('Welcome email failed'),
)
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
await results.continue()
expect(errorSpy).toBeCalledWith('Failed to sendWelcomeEmail', expect.any(Error))
})
it('should throw if failed to set last time online', async () => {
const mockProps = {
deviceToken: 'mockDeviceToken',
adminToken: 'mockAdminToken',
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReferer = {
headers: {
referer: 'mockReferer',
},
}
const mockReq = {get: jest.fn().mockReturnValue(mockReferer)} as any
const mockFirebaseUser = {
providerData: [
{
providerId: 'passwords',
},
],
}
const mockFbUser = {
email: 'mockEmail@mockServer.com',
displayName: 'mockDisplayName',
photoURL: 'mockPhotoUrl',
}
const mockIp = 'mockIP'
const mockBucket = {} as any
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',
}
const mockGetUser = jest
.fn()
.mockResolvedValueOnce(mockFirebaseUser)
.mockResolvedValueOnce(mockFbUser)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(sharedAnalytics.getIp as jest.Mock).mockReturnValue(mockIp)
;(firebaseAdmin.auth as jest.Mock).mockReturnValue({
getUser: mockGetUser,
})
;(usernameUtils.cleanDisplayName as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(firebaseUtils.getBucket as jest.Mock).mockReturnValue(mockBucket)
;(usernameUtils.cleanUsername as jest.Mock).mockReturnValue(mockFbUser.displayName)
;(mockPg.one as jest.Mock).mockResolvedValue(0)
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(false)
;(sharedUtils.getUserByUsername as jest.Mock).mockResolvedValue(false)
;(userNotificationPref.getDefaultNotificationPreferences as jest.Mock).mockReturnValue(null)
;(supabaseUtils.insert as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(null)
;(supabaseUsers.convertUser as jest.Mock).mockReturnValue(mockNewUserRow)
;(supabaseUsers.convertPrivateUser as jest.Mock).mockReturnValue(mockPrivateUserRow)
const results: any = await createUser(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))
})
})
})

View File

@@ -5,7 +5,6 @@ jest.mock('shared/websockets/helpers')
import {AuthedUser} from 'api/helpers/endpoint'
import {updateMe} from 'api/update-me'
import {sqlMatch} from 'common/test-utils'
import * as supabaseInit from 'shared/supabase/init'
import * as supabaseUsers from 'shared/supabase/users'
import * as sharedUtils from 'shared/utils'
@@ -31,18 +30,16 @@ describe('updateMe', () => {
name: 'mockName',
username: 'mockUsername',
avatarUrl: 'mockAvatarUrl',
link: {mockLink: 'mockLinkValue'},
} as any
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
const mockData = {link: mockProps.link}
;(sharedUtils.getUser as jest.Mock).mockResolvedValue(true)
;(sharedUtils.getUserByUsername as jest.Mock).mockReturnValue(false)
;(supabaseUsers.updateUser as jest.Mock)
;(supabaseUsers.updateUserData as jest.Mock)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(mockData)
;(mockPg.oneOrNone as jest.Mock).mockResolvedValue({})
;(mockPg.none as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(null)
;(websocketHelperModules.broadcastUpdatedUser as jest.Mock).mockReturnValue(null)
@@ -52,40 +49,14 @@ describe('updateMe', () => {
expect(sharedUtils.getUser).toBeCalledWith(mockAuth.uid)
expect(sharedUtils.getUserByUsername).toBeCalledTimes(1)
expect(sharedUtils.getUserByUsername).toBeCalledWith(mockProps.username)
expect(supabaseUsers.updateUser).toBeCalledTimes(2)
expect(supabaseUsers.updateUser).toHaveBeenNthCalledWith(
1,
expect(supabaseUsers.updateUserData).toBeCalledTimes(1)
expect(supabaseUsers.updateUserData).toHaveBeenCalledWith(
expect.any(Object),
mockAuth.uid,
{},
)
expect(supabaseUsers.updateUser).toHaveBeenNthCalledWith(
2,
expect.any(Object),
mockAuth.uid,
{avatarUrl: mockProps.avatarUrl},
)
expect(mockPg.oneOrNone).toBeCalledTimes(1)
expect(mockPg.oneOrNone).toBeCalledWith(sqlMatch('update users'), {
adds: expect.any(Object),
removes: expect.any(Array),
id: mockAuth.uid,
})
expect(mockPg.none).toBeCalledTimes(2)
expect(mockPg.none).toHaveBeenNthCalledWith(
1,
sqlMatch(`update users
set name = $1
where id = $2`),
[mockProps.name, mockAuth.uid],
)
expect(mockPg.none).toHaveBeenNthCalledWith(
2,
sqlMatch(`update users
set username = $1
where id = $2`),
[mockProps.username, mockAuth.uid],
)
expect(supabaseUsers.updateUser).toBeCalledTimes(1)
expect(supabaseUsers.updateUser).toHaveBeenCalledWith(mockAuth.uid, mockProps)
expect(websocketHelperModules.broadcastUpdatedUser).toBeCalledTimes(1)
expect(websocketHelperModules.broadcastUpdatedUser).toBeCalledWith({
...mockProps,
@@ -149,7 +120,7 @@ describe('updateMe', () => {
arraySpy.mockReturnValue(false)
;(sharedUtils.getUserByUsername as jest.Mock).mockReturnValue(true)
expect(updateMe(mockProps, mockAuth, mockReq)).rejects.toThrow('Username already taken')
expect(updateMe(mockProps, mockAuth, mockReq)).rejects.toThrow('Username is already taken')
})
})
})

View File

@@ -5,7 +5,7 @@ jest.mock('shared/profiles/parse-photos')
jest.mock('shared/supabase/users')
import {AuthedUser} from 'api/helpers/endpoint'
import {updateProfile} from 'api/update-profile'
import {updateProfileEndpoint} from 'api/update-profile'
import {sqlMatch} from 'common/test-utils'
import {tryCatch} from 'common/util/try-catch'
import {removePinnedUrlFromPhotoUrls} from 'shared/profiles/parse-photos'
@@ -39,7 +39,7 @@ describe('updateProfiles', () => {
.mockResolvedValueOnce({data: true})
.mockResolvedValueOnce({data: mockData, error: null})
const result = await updateProfile(mockProps, mockAuth, mockReq)
const result = await updateProfileEndpoint(mockProps, mockAuth, mockReq)
expect(result).toBe(mockData)
expect(mockPg.oneOrNone).toBeCalledTimes(1)
@@ -49,8 +49,8 @@ describe('updateProfiles', () => {
)
expect(removePinnedUrlFromPhotoUrls).toBeCalledTimes(1)
expect(removePinnedUrlFromPhotoUrls).toBeCalledWith(mockProps)
expect(supabaseUsers.updateUser).toBeCalledTimes(1)
expect(supabaseUsers.updateUser).toBeCalledWith(expect.any(Object), mockAuth.uid, {
expect(supabaseUsers.updateUserData).toBeCalledTimes(1)
expect(supabaseUsers.updateUserData).toBeCalledWith(expect.any(Object), mockAuth.uid, {
avatarUrl: mockProps.pinned_url,
})
expect(supabaseUtils.update).toBeCalledTimes(1)
@@ -66,19 +66,21 @@ describe('updateProfiles', () => {
describe('when an error occurs', () => {
it('should throw if the profile does not exist', async () => {
const mockProps = {
avatar_url: 'mockAvatarUrl',
age: 28,
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
;(tryCatch as jest.Mock).mockResolvedValue({data: false})
expect(updateProfile(mockProps, mockAuth, mockReq)).rejects.toThrow('Profile not found')
expect(updateProfileEndpoint(mockProps, mockAuth, mockReq)).rejects.toThrow(
'Profile not found',
)
})
it('should throw if unable to update the profile', async () => {
const mockProps = {
avatar_url: 'mockAvatarUrl',
age: 28,
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
@@ -87,7 +89,9 @@ describe('updateProfiles', () => {
.mockResolvedValueOnce({data: true})
.mockResolvedValueOnce({data: null, error: Error})
expect(updateProfile(mockProps, mockAuth, mockReq)).rejects.toThrow('Error updating profile')
expect(updateProfileEndpoint(mockProps, mockAuth, mockReq)).rejects.toThrow(
'Error updating profile',
)
})
})
})

View File

@@ -23,10 +23,6 @@ export const mockUser: User = {
id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
username: 'Martin',
name: 'Martin',
link: {
site: 'martinbraquet.com',
x: 'martin',
},
}
export const jamesUser: User = {
@@ -35,9 +31,6 @@ export const jamesUser: User = {
id: '5LZ4LgYuySdL1huCWe7bti02ghx2',
username: 'James',
name: 'James',
link: {
x: 'james',
},
}
export const jamesProfile: PartialProfile = {

View File

@@ -0,0 +1,68 @@
import {runScript} from './run-script'
import {log} from 'shared/monitoring/log'
import {bulkUpdate} from 'shared/supabase/utils'
import {chunk} from 'lodash'
import {removeUndefinedProps} from 'common/util/object'
runScript(async ({pg}) => {
const users = await pg.manyOrNone(`
select u.data, u.id, p.id as pid
from users u
left join profiles p on p.user_id = u.id
`)
log('Found', users.length, 'users to migrate')
const userUpdates: {id: string; avatar_url: string; is_banned_from_posting: boolean}[] = []
const profileUpdates: {id: string; links: Record<string, string>}[] = []
for (const {id, pid, data} of users) {
const {link: links, avatarUrl: avatar_url, isBannedFromPosting: is_banned_from_posting} = data
if (avatar_url || is_banned_from_posting) {
userUpdates.push(
removeUndefinedProps({
id,
avatar_url: avatar_url ?? null,
is_banned_from_posting,
}),
)
}
if (links && pid) {
profileUpdates.push({id: pid, links})
} else if (links && !pid) {
log('Warning: user has links but no profile', id)
}
}
log(`Migrating ${userUpdates.length} users, ${profileUpdates.length} profiles`)
let userCount = 0
for (const batch of chunk(userUpdates, 100)) {
await bulkUpdate(pg, 'users', ['id'], batch)
log('Updated users', (userCount += batch.length))
}
let profileCount = 0
for (const batch of chunk(profileUpdates, 100)) {
// If not, write the profiles update directly:
await pg.none(`
UPDATE profiles SET links = v.links::jsonb
FROM (VALUES ${batch.map((r) => `(${r.id}, '${JSON.stringify(r.links)}')`).join(',')})
AS v(id, links)
WHERE profiles.id = v.id::bigint
`)
log('Updated profiles', (profileCount += batch.length))
}
// Strip migrated fields from the JSON blob
await pg.none(`
update users
set data = data - '{link,avatarUrl,isBannedFromPosting}'::text[]
`)
log('Migration complete')
process.exit(0)
})

View File

@@ -0,0 +1,42 @@
import {runScript} from './run-script'
import {log} from 'shared/monitoring/log'
import {chunk} from 'lodash'
import {Client} from 'pg'
runScript(async ({pg}) => {
// Connect to local Supabase emulator
const localClient = new Client({
host: '127.0.0.1',
port: 54322,
user: 'postgres',
password: 'postgres',
database: 'postgres',
})
await localClient.connect()
log('Connected to local emulator')
// Read all user data from the backup
let {rows} = await localClient.query<{id: string; data: object}>(`SELECT id, data FROM users`)
await localClient.end()
// rows = rows.slice(0, 5)
// log(rows)
log(`Found ${rows.length} users in backup`)
// Update remote in batches
let count = 0
for (const batch of chunk(rows, 100)) {
await pg.none(
`UPDATE users AS target
SET data = v.data::jsonb
FROM (VALUES ${batch.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', ')})
AS v(id, data)
WHERE target.id = v.id`,
batch.flatMap((r) => [r.id, JSON.stringify(r.data)]),
)
log(`Restored data for ${(count += batch.length)} / ${rows.length} users`)
}
log('Done restoring data column from backup')
})

View File

@@ -66,8 +66,8 @@ export const getGenderCompatibleProfiles = async (profile: ProfileRow) => {
users on users.id = profiles.user_id
where user_id != $(user_id)
and looking_for_matches
and (data ->> 'isBannedFromPosting' != 'true' or data ->> 'isBannedFromPosting' is null)
and (data ->> 'userDeleted' != 'true' or data ->> 'userDeleted' is null)
and not is_banned_from_posting
-- and (data ->> 'userDeleted' != 'true' or data ->> 'userDeleted' is null)
and profiles.pinned_url is not null
`,
{...profile},
@@ -86,8 +86,8 @@ export const getCompatibleProfiles = async (profile: ProfileRow, radiusKm: numbe
users on users.id = profiles.user_id
where user_id != $(user_id)
and looking_for_matches
and (data ->> 'isBannedFromPosting' != 'true' or data ->> 'isBannedFromPosting' is null)
and (data ->> 'userDeleted' != 'true' or data ->> 'userDeleted' is null)
and not is_banned_from_posting
-- and (data ->> 'userDeleted' != 'true' or data ->> 'userDeleted' is null)
-- Gender
and (profiles.gender = any ($(pref_gender)) or profiles.gender = 'non-binary')

View File

@@ -1,21 +1,52 @@
import {ProfileRow} from 'common/profiles/profile'
import {convertUserToDb} from 'common/supabase/users'
import {User} from 'common/user'
import {SupabaseDirectClient} from 'shared/supabase/init'
import {removeUndefinedProps} from 'common/util/object'
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
import {broadcastUpdatedPrivateUser, broadcastUpdatedUser} from 'shared/websockets/helpers'
import {DataUpdate, updateData} from './utils'
import {DataUpdate, update, updateData} from './utils'
export const updateProfile = async (user_id: string, updated: Partial<ProfileRow>) => {
updated = removeUndefinedProps(updated)
// if (!updated) return
const fullUpdate = {user_id, ...updated}
const pg = createSupabaseDirectClient()
return await update(pg, 'profiles', 'user_id', fullUpdate)
}
export const updateUser = async (id: string, updated: Partial<User>) => {
updated = removeUndefinedProps(updated)
if (!updated) return
const fullUpdate = {id, ...updated}
const pg = createSupabaseDirectClient()
const result = await update(pg, 'users', 'id', convertUserToDb(fullUpdate))
broadcastUpdatedUser(fullUpdate)
return result
}
/** only updates data column. do not use for name, username */
export const updateUser = async (db: SupabaseDirectClient, id: string, update: Partial<User>) => {
const fullUpdate = {id, ...update}
await updateData(db, 'users', 'id', fullUpdate)
broadcastUpdatedUser(fullUpdate)
export const updateUserData = async (
db: SupabaseDirectClient,
id: string,
updated: DataUpdate<'users'>,
) => {
updated = removeUndefinedProps(updated)
if (!updated) return
const fullUpdate = {id, ...updated}
const result = await updateData(db, 'users', 'id', fullUpdate)
broadcastUpdatedUser(fullUpdate as any) // maybe fix
return result
}
export const updatePrivateUser = async (
db: SupabaseDirectClient,
id: string,
update: DataUpdate<'private_users'>,
updated: DataUpdate<'private_users'>,
) => {
await updateData(db, 'private_users', 'id', {id, ...update})
updated = removeUndefinedProps(updated)
if (!updated) return
const result = await updateData(db, 'private_users', 'id', {id, ...updated})
broadcastUpdatedPrivateUser(id)
return result
}

View File

@@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS profiles
image_descriptions jsonb,
is_smoker BOOLEAN,
last_modification_time TIMESTAMPTZ DEFAULT now() NOT NULL,
links JSONB default '{}'::jsonb not null,
looking_for_matches BOOLEAN DEFAULT TRUE NOT NULL,
allow_direct_messaging BOOLEAN DEFAULT TRUE NOT NULL,
allow_interest_indicating BOOLEAN DEFAULT TRUE NOT NULL,
@@ -61,12 +62,10 @@ CREATE TABLE IF NOT EXISTS profiles
religion TEXT[],
religious_belief_strength INTEGER,
religious_beliefs TEXT,
twitter TEXT,
university TEXT,
user_id TEXT NOT NULL,
visibility profile_visibility DEFAULT 'member'::profile_visibility NOT NULL,
wants_kids_strength INTEGER DEFAULT 0,
website TEXT,
CONSTRAINT profiles_pkey PRIMARY KEY (id)
);

View File

@@ -1,26 +1,31 @@
CREATE TABLE IF NOT EXISTS users (
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
data JSONB NOT NULL,
id TEXT DEFAULT random_alphanumeric(12) NOT NULL,
name TEXT NOT NULL,
name_username_vector tsvector GENERATED ALWAYS AS (
CREATE TABLE IF NOT EXISTS users
(
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
data JSONB NOT NULL,
id TEXT DEFAULT random_alphanumeric(12) NOT NULL,
name TEXT NOT NULL,
name_username_vector tsvector GENERATED ALWAYS AS (
to_tsvector(
'english'::regconfig,
(name || ' '::text) || username
'english'::regconfig,
(name || ' '::text) || username
)
) STORED,
username TEXT NOT NULL,
) STORED,
username TEXT NOT NULL,
avatar_url TEXT,
is_banned_from_posting BOOLEAN DEFAULT FALSE NOT NULL,
CONSTRAINT users_pkey PRIMARY KEY (id)
);
);
-- Row Level Security
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE users
ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON users;
CREATE POLICY "public read" ON users
FOR SELECT
USING (true);
FOR SELECT
USING (true);
-- Indexes
CREATE INDEX IF NOT EXISTS user_username_idx ON public.users USING btree (username);
@@ -28,7 +33,3 @@ CREATE INDEX IF NOT EXISTS user_username_idx ON public.users USING btree (userna
CREATE INDEX IF NOT EXISTS users_created_time ON public.users USING btree (created_time DESC);
CREATE INDEX IF NOT EXISTS users_name_idx ON public.users USING btree (name);
-- Remove these if you trust PRIMARY KEY auto-index:
-- DROP INDEX IF EXISTS users_pkey;
-- CREATE UNIQUE INDEX users_pkey ON public.users USING btree (id);

View File

@@ -309,7 +309,6 @@ export const API = (_apiTypeCheck = {
locale: z.string().optional(),
username: z.string().min(1),
name: z.string().min(1),
link: z.record(z.string().nullable()).optional(),
profile: combinedProfileSchema,
interests: arraybeSchema.optional(),
causes: arraybeSchema.optional(),
@@ -378,7 +377,6 @@ export const API = (_apiTypeCheck = {
name: z.string().trim().min(1).optional(),
username: z.string().trim().min(1).optional(),
avatarUrl: z.string().optional(),
link: z.record(z.string().nullable()).optional(),
}),
returns: {} as FullUser,
summary: 'Update authenticated user profile and settings',

View File

@@ -1,4 +1,4 @@
import {ENV_CONFIG, MOD_USERNAMES} from 'common/envs/constants'
import {ENV_CONFIG, MOD_USERNAMES, WEB_URL} from 'common/envs/constants'
import {User} from 'common/user'
import {removeUndefinedProps} from 'common/util/object'
@@ -20,7 +20,7 @@ export type FullUser = User & {
export function toUserAPIResponse(user: User): FullUser {
return removeUndefinedProps({
...user,
url: `https://${ENV_CONFIG.domain}/${user.username}`,
url: `${WEB_URL}/${user.username}`,
isAdmin: ENV_CONFIG.adminIds.includes(user.id),
isTrustworthy: MOD_USERNAMES.includes(user.username),
})

View File

@@ -70,7 +70,6 @@ export const baseProfilesSchema = z.object({
})
const optionalProfilesSchema = z.object({
avatar_url: z.string().optional().nullable(),
bio: contentSchema.optional().nullable(),
big5_openness: z.number().min(0).max(100).optional().nullable(),
big5_conscientiousness: z.number().min(0).max(100).optional().nullable(),
@@ -96,6 +95,7 @@ const optionalProfilesSchema = z.object({
image_descriptions: z.any().optional().nullable(),
interests: z.array(z.string()).optional().nullable(),
is_smoker: zBoolean.optional().nullable(),
links: z.record(z.string().nullable()).optional(),
mbti: z.string().optional().nullable(),
occupation: z.string().optional().nullable(),
occupation_title: z.string().optional().nullable(),

View File

@@ -1105,12 +1105,13 @@ export type Database = {
has_kids: number | null
headline: string | null
height_in_inches: number | null
keywords: string[] | null
id: number
image_descriptions: Json | null
is_smoker: boolean | null
keywords: string[] | null
languages: string[] | null
last_modification_time: string
links: Json
looking_for_matches: boolean
mbti: string | null
messaging_status: string
@@ -1140,12 +1141,10 @@ export type Database = {
religious_beliefs: string | null
search_text: string | null
search_tsv: unknown
twitter: string | null
university: string | null
user_id: string
visibility: Database['public']['Enums']['lover_visibility']
wants_kids_strength: number | null
website: string | null
}
Insert: {
age?: number | null
@@ -1180,10 +1179,11 @@ export type Database = {
height_in_inches?: number | null
id?: number
image_descriptions?: Json | null
keywords?: string[] | null
is_smoker?: boolean | null
keywords?: string[] | null
languages?: string[] | null
last_modification_time?: string
links?: Json
looking_for_matches?: boolean
mbti?: string | null
messaging_status?: string
@@ -1213,12 +1213,10 @@ export type Database = {
religious_beliefs?: string | null
search_text?: string | null
search_tsv?: unknown
twitter?: string | null
university?: string | null
user_id: string
visibility?: Database['public']['Enums']['lover_visibility']
wants_kids_strength?: number | null
website?: string | null
}
Update: {
age?: number | null
@@ -1253,10 +1251,11 @@ export type Database = {
height_in_inches?: number | null
id?: number
image_descriptions?: Json | null
keywords?: string[] | null
is_smoker?: boolean | null
keywords?: string[] | null
languages?: string[] | null
last_modification_time?: string
links?: Json
looking_for_matches?: boolean
mbti?: string | null
messaging_status?: string
@@ -1286,12 +1285,10 @@ export type Database = {
religious_beliefs?: string | null
search_text?: string | null
search_tsv?: unknown
twitter?: string | null
university?: string | null
user_id?: string
visibility?: Database['public']['Enums']['lover_visibility']
wants_kids_strength?: number | null
website?: string | null
}
Relationships: [
{
@@ -1538,25 +1535,31 @@ export type Database = {
}
users: {
Row: {
avatar_url: string | null
created_time: string
data: Json
id: string
is_banned_from_posting: boolean | null
name: string
name_username_vector: unknown
username: string
}
Insert: {
avatar_url?: string | null
created_time?: string
data: Json
id?: string
is_banned_from_posting?: boolean | null
name: string
name_username_vector?: unknown
username: string
}
Update: {
avatar_url?: string | null
created_time?: string
data?: Json
id?: string
is_banned_from_posting?: boolean | null
name?: string
name_username_vector?: unknown
username?: string
@@ -1741,7 +1744,7 @@ export type Database = {
is_admin: {Args: never; Returns: boolean} | {Args: {user_id: string}; Returns: boolean}
millis_interval: {
Args: {end_millis: number; start_millis: number}
Returns: unknown
Returns: string
}
millis_to_ts: {Args: {millis: number}; Returns: string}
random_alphanumeric: {Args: {length: number}; Returns: string}

View File

@@ -1,23 +1,49 @@
import {PrivateUser, User} from 'common/user'
import {removeUndefinedProps} from 'common/util/object'
import {Row, run, SupabaseClient, tsToMillis} from './utils'
import {millisToTs, Row, run, SupabaseClient, tsToMillis} from './utils'
export async function getUserForStaticProps(db: SupabaseClient, username: string) {
const {data} = await run(db.from('users').select().ilike('username', username))
return convertUser(data[0] ?? null)
}
export function convertUser(row: Row<'users'>): User
export function convertUser(row: Row<'users'> | null): User | null {
if (!row) return null
function toDb(row: any): any {
return {
...(row.data as any),
id: row.id,
username: row.username,
name: row.name,
createdTime: tsToMillis(row.created_time),
} as User
avatarUrl: row.avatar_url,
isBannedFromPosting: row.is_banned_from_posting,
createdTime: row.created_time ? tsToMillis(row.created_time) : undefined,
}
}
// From DB to typescript
export function convertUser(row: Row<'users'>): User
export function convertUser(row: Row<'users'> | null): User | null {
if (!row) return null
return toDb(row)
}
export function convertPartialUser(row: Partial<Row<'users'>>): Partial<User> {
return removeUndefinedProps(toDb(row))
}
// Reciprocal of convertUser, from typescript to DB
export function convertUserToDb(user: Partial<User>): Partial<Row<'users'>>
export function convertUserToDb(user: Partial<User> | null): Partial<Row<'users'>> | null {
if (!user) return null
return removeUndefinedProps({
id: user.id,
username: user.username,
name: user.name,
avatar_url: user.avatarUrl,
is_banned_from_posting: user.isBannedFromPosting,
created_time: millisToTs(user.createdTime),
})
}
export function convertPrivateUser(row: Row<'private_users'>): PrivateUser
@@ -26,4 +52,4 @@ export function convertPrivateUser(row: Row<'private_users'> | null): PrivateUse
return row.data as PrivateUser
}
export const displayUserColumns = `id,name,username,data->>'avatarUrl' as "avatarUrl",data->'isBannedFromPosting' as "isBannedFromPosting"`
export const displayUserColumns = `id,name,username, avatar_url as "avatarUrl",is_banned_from_posting as "isBannedFromPosting"`

View File

@@ -92,7 +92,8 @@ export function selectFrom<
return db.from(table).select<string, TResult>(query)
}
export function millisToTs(millis: number) {
export function millisToTs(millis: number | undefined) {
if (!millis) return
return new Date(millis).toISOString()
}

View File

@@ -1,11 +1,9 @@
import {Socials} from './socials'
import {notification_preferences} from './user-notification-preferences'
export type BaseUser = {
id: string
name: string
username: string
link: Socials // Social links
}
export type User = BaseUser & {

View File

@@ -67,12 +67,6 @@ export async function seedDbUser(userInfo: UserAccountInformation, profileType?:
const profileData =
profileType === 'basic' ? basicProfile : profileType === 'medium' ? mediumProfile : fullProfile
const user = {
// avatarUrl,
isBannedFromPosting: false,
link: {},
}
const privateUser: PrivateUser = {
id: userId,
email: userInfo.email,
@@ -91,7 +85,7 @@ export async function seedDbUser(userInfo: UserAccountInformation, profileType?:
id: userId,
name: userInfo.name,
username: cleanUsername(userInfo.name),
data: user,
data: {},
})
await insert(tx, 'private_users', {

View File

@@ -17,7 +17,7 @@ import {
import {debug} from 'common/logger'
import {MultipleChoiceOptions} from 'common/profiles/multiple-choice'
import {Profile, ProfileWithoutUser} from 'common/profiles/profile'
import {PLATFORM_LABELS, type Site, SITE_ORDER} from 'common/socials'
import {PLATFORM_LABELS, type Site, SITE_ORDER, Socials} from 'common/socials'
import {BaseUser} from 'common/user'
import {range} from 'lodash'
import {Fragment, useEffect, useRef, useState} from 'react'
@@ -51,20 +51,11 @@ export const OptionalProfileUserForm = (props: {
profile: ProfileWithoutUser
setProfile: <K extends keyof ProfileWithoutUser>(key: K, value: ProfileWithoutUser[K]) => void
user: BaseUser
setUser: <K extends keyof BaseUser>(key: K, value: BaseUser[K]) => void
buttonLabel?: string
bottomNavBarVisible?: boolean
onSubmit: () => Promise<void>
}) => {
const {
profile,
user,
buttonLabel,
setProfile,
setUser,
onSubmit,
bottomNavBarVisible = true,
} = props
const {profile, user, buttonLabel, setProfile, onSubmit, bottomNavBarVisible = true} = props
const [isSubmitting, setIsSubmitting] = useState(false)
const [lookingRelationship, setLookingRelationship] = useState(
@@ -125,7 +116,7 @@ export const OptionalProfileUserForm = (props: {
}
const updateUserLink = (platform: string, value: string | null) => {
setUser('link', {...user.link, [platform]: value})
setProfile('links', {...((profile.links as Socials) ?? {}), [platform]: value})
}
const addNewLink = () => {
@@ -913,7 +904,7 @@ export const OptionalProfileUserForm = (props: {
{/*</label>*/}
<div className="grid w-full grid-cols-[8rem_1fr_auto] gap-2">
{Object.entries(user.link)
{Object.entries(profile.links as Socials)
.filter(([_, value]) => value != null)
.map(([platform, value]) => (
<Fragment key={platform}>

View File

@@ -14,6 +14,7 @@ import {convertGenderPlural, Gender} from 'common/gender'
import {getLocationText} from 'common/geodb'
import {formatHeight, MeasurementSystem} from 'common/measurement-utils'
import {Profile} from 'common/profiles/profile'
import {Socials} from 'common/socials'
import {UserActivity} from 'common/user'
import {Home} from 'lucide-react'
import React, {ReactNode} from 'react'
@@ -171,7 +172,7 @@ export default function ProfileAbout(props: {
<HasKids profile={profile} />
<WantsKids profile={profile} />
{!isCurrentUser && <LastOnline lastOnlineTime={userActivity?.last_online_time} />}
<UserHandles links={profile.user.link} />
<UserHandles links={profile.links as Socials} />
</Col>
)
}

View File

@@ -149,7 +149,7 @@ function FreshBadge() {
// name: string
// username: string
// createdTime: number
// isBannedFromPosting?: boolean
// is_banned_from_posting?: boolean
// }
// followsYou?: boolean
// className?: string
@@ -167,7 +167,7 @@ function FreshBadge() {
// fresh={isFresh(user.createdTime)}
// />
// }
// {user.isBannedFromPosting && <BannedBadge/>}
// {user.is_banned_from_posting && <BannedBadge/>}
// </div>
// <Row className={'flex-shrink flex-wrap gap-x-2'}>
// <span className={clsx('text-ink-400 text-sm', usernameClassName)}>

View File

@@ -1,5 +1,6 @@
import type {DisplayUser} from 'common/api/user-types'
import {APIError} from 'common/api/utils'
import {convertPartialUser} from 'common/supabase/users'
import {run, TableName} from 'common/supabase/utils'
import {MONTH_MS} from 'common/util/time'
import {api} from 'web/lib/api'
@@ -51,11 +52,11 @@ export async function getDisplayUsers(userIds: string[]) {
const {data} = await run(
db
.from('users')
.select(`id, name, username, data->avatarUrl, data->isBannedFromPosting`)
.select(`id, name, username, avatar_url, is_banned_from_posting`)
.in('id', userIds),
)
return data as unknown as DisplayUser[]
return data.map(convertPartialUser) as unknown as DisplayUser[]
}
export async function getProfilesCreations() {

View File

@@ -114,7 +114,6 @@ function ProfilePageInner(props: {user: User; profile: Profile}) {
profile={profile}
setProfile={setProfileState}
user={baseUser}
setUser={setBaseUserState}
buttonLabel={t('profile.save', 'Save')}
onSubmit={async () => await submitForm()}
/>

View File

@@ -92,7 +92,6 @@ export default function SignupPage() {
name,
locale,
deviceToken,
link: baseUser.link,
profile: otherProfileProps,
interests,
causes,
@@ -142,7 +141,6 @@ export default function SignupPage() {
profile={profileForm}
setProfile={setProfileState}
user={baseUser}
setUser={setBaseUserState}
bottomNavBarVisible={false}
onSubmit={handleFinalSubmit}
/>
@@ -168,7 +166,6 @@ function getInitialBaseUser() {
id: auth.currentUser?.uid ?? '',
username: cleanUsername(name),
name: name,
link: {},
}
return initialState
}