mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-02-23 10:26:16 -05:00
Pre compute compatibility scores for faster profile lookup
This commit is contained in:
@@ -1,62 +1,29 @@
|
||||
import {groupBy, sortBy} from 'lodash'
|
||||
import {APIError, type APIHandler} from 'api/helpers/endpoint'
|
||||
import {getCompatibilityScore, hasAnsweredQuestions} from 'common/profiles/compatibility-score'
|
||||
import {getCompatibilityAnswers, getGenderCompatibleProfiles, getProfile,} from 'shared/profiles/supabase'
|
||||
import {log} from 'shared/utils'
|
||||
import {type APIHandler} from 'api/helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from "shared/supabase/init";
|
||||
|
||||
export const getCompatibleProfilesHandler: APIHandler<'compatible-profiles'> = async (props) => {
|
||||
return getCompatibleProfiles(props.userId, false)
|
||||
return getCompatibleProfiles(props.userId)
|
||||
}
|
||||
|
||||
export const getCompatibleProfiles = async (
|
||||
userId: string,
|
||||
includeProfilesWithoutPromptAnswers: boolean = true,
|
||||
) => {
|
||||
const profile = await getProfile(userId)
|
||||
|
||||
log('got profile', {
|
||||
id: profile?.id,
|
||||
userId: profile?.user_id,
|
||||
username: profile?.user?.username,
|
||||
})
|
||||
|
||||
if (!profile) throw new APIError(404, 'Profile not found')
|
||||
|
||||
let profiles = await getGenderCompatibleProfiles(profile)
|
||||
|
||||
const profileAnswers = await getCompatibilityAnswers([
|
||||
userId,
|
||||
...profiles.map((l) => l.user_id),
|
||||
])
|
||||
log('got profile answers ' + profileAnswers.length)
|
||||
|
||||
const answersByUserId = groupBy(profileAnswers, 'creator_id')
|
||||
if (!includeProfilesWithoutPromptAnswers) {
|
||||
profiles = profiles.filter((l) => hasAnsweredQuestions(answersByUserId[l.user_id]))
|
||||
if (!hasAnsweredQuestions(answersByUserId[profile.user_id])) profiles = []
|
||||
}
|
||||
const profileCompatibilityScores = Object.fromEntries(
|
||||
profiles.map(
|
||||
(l) =>
|
||||
[
|
||||
l.user_id,
|
||||
getCompatibilityScore(
|
||||
answersByUserId[profile.user_id] ?? [],
|
||||
answersByUserId[l.user_id] ?? []
|
||||
),
|
||||
] as const
|
||||
)
|
||||
const pg = createSupabaseDirectClient()
|
||||
const scores = await pg.map(
|
||||
`select *
|
||||
from compatibility_scores
|
||||
where score is not null
|
||||
and (user_id_1 = $1 or user_id_2 = $1)`,
|
||||
[userId],
|
||||
(r) => [r.user_id_1 == userId ? r.user_id_2 : r.user_id_1, {score: r.score}] as const
|
||||
)
|
||||
|
||||
const sortedCompatibleProfiles = sortBy(
|
||||
profiles,
|
||||
(l) => profileCompatibilityScores[l.user_id].score
|
||||
).reverse()
|
||||
const profileCompatibilityScores = Object.fromEntries(scores)
|
||||
|
||||
// console.log('scores', profileCompatibilityScores)
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
profile,
|
||||
compatibleProfiles: sortedCompatibleProfiles,
|
||||
profileCompatibilityScores,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {APIHandler} from 'api/helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {APIError} from 'common/api/utils'
|
||||
import {recomputeCompatibilityScoresForUser} from 'shared/compatibility/compute-scores'
|
||||
|
||||
export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'> = async (
|
||||
{id}, auth) => {
|
||||
@@ -27,4 +28,14 @@ export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'
|
||||
AND creator_id = $2`,
|
||||
[id, auth.uid]
|
||||
)
|
||||
|
||||
const continuation = async () => {
|
||||
// Recompute precomputed compatibility scores for this user
|
||||
await recomputeCompatibilityScoresForUser(auth.uid, pg)
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
continue: continuation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import {type APIHandler} from 'api/helpers/endpoint'
|
||||
import {convertRow} from 'shared/profiles/supabase'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {createSupabaseDirectClient, pgp} from 'shared/supabase/init'
|
||||
import {from, join, leftJoin, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
|
||||
import {getCompatibleProfiles} from 'api/compatible-profiles'
|
||||
import {intersection} from 'lodash'
|
||||
import {MAX_INT, MIN_BIO_LENGTH, MIN_INT} from "common/constants";
|
||||
import {MIN_BIO_LENGTH} from "common/constants";
|
||||
import {compact} from "lodash";
|
||||
|
||||
export type profileQueryType = {
|
||||
limit?: number | undefined,
|
||||
@@ -40,7 +39,7 @@ export type profileQueryType = {
|
||||
lastModificationWithin?: string | undefined,
|
||||
}
|
||||
|
||||
const userActivityColumns = ['last_online_time']
|
||||
// const userActivityColumns = ['last_online_time']
|
||||
|
||||
|
||||
export const loadProfiles = async (props: profileQueryType) => {
|
||||
@@ -84,86 +83,35 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : []
|
||||
// console.debug('keywords:', keywords)
|
||||
|
||||
// TODO: do this in SQL for better performance. Precompute compatibility scores:
|
||||
// - Have a table compatibility_scores(user_id_1, user_id_2, score) that updates whenever answers from either user change.
|
||||
// - Query this table directly with "ORDER BY score DESC LIMIT {limit}".
|
||||
if (orderByParam === 'compatibility_score') {
|
||||
if (!compatibleWithUserId) {
|
||||
console.error('Incompatible with user ID')
|
||||
throw Error('Incompatible with user ID')
|
||||
}
|
||||
|
||||
const {compatibleProfiles} = await getCompatibleProfiles(compatibleWithUserId)
|
||||
let profiles = compatibleProfiles.filter(
|
||||
(l) =>
|
||||
(!name || l.user.name.toLowerCase().includes(name.toLowerCase())) &&
|
||||
(!genders || genders.includes(l.gender ?? '')) &&
|
||||
(!education_levels || education_levels.includes(l.education_level ?? '')) &&
|
||||
(!mbti || mbti.includes(l.mbti ?? '')) &&
|
||||
(!pref_gender || intersection(pref_gender, l.pref_gender).length) &&
|
||||
(!pref_age_min || (l.age ?? MAX_INT) >= pref_age_min) &&
|
||||
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_max) &&
|
||||
(!drinks_min || (l.drinks_per_month ?? MAX_INT) >= drinks_min) &&
|
||||
(!drinks_max || (l.drinks_per_month ?? MIN_INT) <= drinks_max) &&
|
||||
(!pref_relation_styles ||
|
||||
intersection(pref_relation_styles, l.pref_relation_styles).length) &&
|
||||
(!pref_romantic_styles ||
|
||||
intersection(pref_romantic_styles, l.pref_romantic_styles).length) &&
|
||||
(!diet ||
|
||||
intersection(diet, l.diet).length) &&
|
||||
(!political_beliefs ||
|
||||
intersection(political_beliefs, l.political_beliefs).length) &&
|
||||
(!relationship_status ||
|
||||
intersection(relationship_status, l.relationship_status).length) &&
|
||||
(!languages ||
|
||||
intersection(languages, l.languages).length) &&
|
||||
(!religion ||
|
||||
intersection(religion, l.religion).length) &&
|
||||
(!wants_kids_strength ||
|
||||
wants_kids_strength == -1 ||
|
||||
!l.wants_kids_strength ||
|
||||
l.wants_kids_strength == -1 ||
|
||||
(wants_kids_strength >= 2
|
||||
? l.wants_kids_strength >= wants_kids_strength
|
||||
: l.wants_kids_strength <= wants_kids_strength)) &&
|
||||
(has_kids == undefined ||
|
||||
has_kids == -1 ||
|
||||
(has_kids == 0 && !l.has_kids) ||
|
||||
(l.has_kids && l.has_kids > 0)) &&
|
||||
(is_smoker === undefined || l.is_smoker === is_smoker) &&
|
||||
(!l.disabled) &&
|
||||
(l.id.toString() != skipId) &&
|
||||
(!geodbCityIds ||
|
||||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id))) &&
|
||||
(!filterLocation || (
|
||||
l.city_latitude && l.city_longitude &&
|
||||
Math.abs(l.city_latitude - lat) < radius / 69.0 &&
|
||||
Math.abs(l.city_longitude - lon) < radius / (69.0 * Math.cos(lat * Math.PI / 180)) &&
|
||||
Math.pow(l.city_latitude - lat, 2) + Math.pow((l.city_longitude - lon) * Math.cos(lat * Math.PI / 180), 2) < Math.pow(radius / 69.0, 2)
|
||||
)) &&
|
||||
(shortBio || (l.bio_length ?? 0) >= MIN_BIO_LENGTH)
|
||||
)
|
||||
|
||||
const count = profiles.length
|
||||
|
||||
const cursor = after
|
||||
? profiles.findIndex((l) => l.id.toString() === after) + 1
|
||||
: 0
|
||||
console.debug(cursor)
|
||||
|
||||
if (limitParam) profiles = profiles.slice(cursor, cursor + limitParam)
|
||||
|
||||
return {profiles, count}
|
||||
if (orderByParam === 'compatibility_score' && !compatibleWithUserId) {
|
||||
console.error('Incompatible with user ID')
|
||||
throw Error('Incompatible with user ID')
|
||||
}
|
||||
|
||||
const tablePrefix = userActivityColumns.includes(orderByParam) ? 'user_activity' : 'profiles'
|
||||
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
|
||||
const tablePrefix = orderByParam === 'compatibility_score'
|
||||
? 'compatibility_scores'
|
||||
: orderByParam === 'last_online_time'
|
||||
? 'user_activity'
|
||||
: 'profiles'
|
||||
|
||||
const tableSelection = [
|
||||
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
|
||||
const compatibilityScoreJoin = pgp.as.format(`compatibility_scores cs on (cs.user_id_1 = LEAST(profiles.user_id, $(compatibleWithUserId)) and cs.user_id_2 = GREATEST(profiles.user_id, $(compatibleWithUserId)))`, {compatibleWithUserId})
|
||||
|
||||
const _orderBy = orderByParam === 'compatibility_score' ? 'cs.score' : `${tablePrefix}.${orderByParam}`
|
||||
const afterFilter = renderSql(
|
||||
select(_orderBy),
|
||||
from('profiles'),
|
||||
orderByParam === 'last_online_time' && leftJoin(userActivityJoin),
|
||||
orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin),
|
||||
where('profiles.id = $(after)', {after}),
|
||||
)
|
||||
|
||||
const tableSelection = compact([
|
||||
from('profiles'),
|
||||
join('users on users.id = profiles.user_id'),
|
||||
leftJoin(userActivityJoin),
|
||||
]
|
||||
orderByParam === 'last_online_time' && leftJoin(userActivityJoin),
|
||||
orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin),
|
||||
])
|
||||
|
||||
const filters = [
|
||||
where('looking_for_matches = true'),
|
||||
@@ -281,29 +229,27 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
|
||||
]
|
||||
|
||||
let selectCols = 'profiles.*, name, username, users.data as user'
|
||||
if (orderByParam === 'compatibility_score') {
|
||||
selectCols += ', cs.score as compatibility_score'
|
||||
} else if (orderByParam === 'last_online_time') {
|
||||
selectCols += ', user_activity.last_online_time'
|
||||
}
|
||||
|
||||
const query = renderSql(
|
||||
select('profiles.*, name, username, users.data as user, user_activity.last_online_time'),
|
||||
select(selectCols),
|
||||
...tableSelection,
|
||||
...filters,
|
||||
|
||||
orderBy(`${tablePrefix}.${orderByParam} DESC`),
|
||||
|
||||
after && where(
|
||||
`${tablePrefix}.${orderByParam} < (
|
||||
SELECT ${tablePrefix}.${orderByParam}
|
||||
FROM profiles
|
||||
LEFT JOIN ${userActivityJoin}
|
||||
WHERE profiles.id = $(after)
|
||||
)`, {after}),
|
||||
|
||||
orderBy(`${_orderBy} DESC`),
|
||||
after && where(`${_orderBy} < (${afterFilter})`),
|
||||
limitParam && limit(limitParam),
|
||||
)
|
||||
|
||||
// console.debug('query:', query)
|
||||
console.debug('query:', query)
|
||||
|
||||
const profiles = await pg.map(query, [], convertRow)
|
||||
|
||||
console.debug('profiles:', profiles)
|
||||
// console.debug('profiles:', profiles)
|
||||
|
||||
const countQuery = renderSql(
|
||||
select(`count(*) as count`),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {recomputeCompatibilityScoresForUser} from 'shared/compatibility/compute-scores'
|
||||
|
||||
export const setCompatibilityAnswer: APIHandler<'set-compatibility-answer'> = async (
|
||||
{questionId, multipleChoice, prefChoices, importance, explanation},
|
||||
@@ -30,5 +31,13 @@ export const setCompatibilityAnswer: APIHandler<'set-compatibility-answer'> = as
|
||||
],
|
||||
})
|
||||
|
||||
return result
|
||||
const continuation = async () => {
|
||||
// Recompute precomputed compatibility scores for this user
|
||||
await recomputeCompatibilityScoresForUser(auth.uid, pg)
|
||||
}
|
||||
|
||||
return {
|
||||
result: result,
|
||||
continue: continuation,
|
||||
}
|
||||
}
|
||||
|
||||
99
backend/shared/src/compatibility/compute-scores.ts
Normal file
99
backend/shared/src/compatibility/compute-scores.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {SupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {Row as RowFor} from 'common/supabase/utils'
|
||||
import {getCompatibilityScore, hasAnsweredQuestions} from 'common/profiles/compatibility-score'
|
||||
import {getCompatibilityAnswers, getGenderCompatibleProfiles, getProfile} from "shared/profiles/supabase"
|
||||
import {groupBy} from "lodash"
|
||||
import {hrtime} from "node:process"
|
||||
|
||||
type AnswerRow = RowFor<'compatibility_answers'>
|
||||
|
||||
// Canonicalize pair ordering (user_id_1 < user_id_2 lexicographically)
|
||||
function canonicalPair(a: string, b: string) {
|
||||
return a < b ? [a, b] as const : [b, a] as const
|
||||
}
|
||||
|
||||
export async function recomputeCompatibilityScoresForUser(
|
||||
userId: string,
|
||||
pg: SupabaseDirectClient,
|
||||
) {
|
||||
const startTs = hrtime.bigint()
|
||||
|
||||
// Load all answers for the target user
|
||||
const answersSelf = await pg.manyOrNone<AnswerRow>(
|
||||
'select * from compatibility_answers where creator_id = $1',
|
||||
[userId]
|
||||
)
|
||||
|
||||
// If the user has no answered questions, set the score to null
|
||||
if (!hasAnsweredQuestions(answersSelf)) {
|
||||
await pg.none(
|
||||
`update compatibility_scores
|
||||
set score = null
|
||||
where user_id_1 = $1
|
||||
or user_id_2 = $1`,
|
||||
[userId]
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const profile = await getProfile(userId, pg)
|
||||
if (!profile) throw new Error(`Profile not found for user ${userId}`)
|
||||
let profiles = await getGenderCompatibleProfiles(profile)
|
||||
const otherUserIds = profiles.map((l) => l.user_id)
|
||||
const profileAnswers = await getCompatibilityAnswers([userId, ...otherUserIds])
|
||||
const answersByUser = groupBy(profileAnswers, 'creator_id')
|
||||
|
||||
console.log(`Recomputing compatibility scores for user ${userId}, ${otherUserIds.length} other users.`)
|
||||
|
||||
const rows = []
|
||||
|
||||
for (const otherId of otherUserIds) {
|
||||
const answersOther = answersByUser[otherId] ?? []
|
||||
if (!hasAnsweredQuestions(answersOther)) continue
|
||||
|
||||
const {score} = getCompatibilityScore(answersSelf, answersOther)
|
||||
const adaptedScore = score + (Math.random() - 0.5) * 0.001 // Add some noise to avoid ties (for profile sorting / pagination)
|
||||
|
||||
const [u1, u2] = canonicalPair(userId, otherId)
|
||||
rows.push([u1, u2, adaptedScore])
|
||||
}
|
||||
|
||||
if (rows.length === 0) return
|
||||
|
||||
const values = rows
|
||||
.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`)
|
||||
.join(", ");
|
||||
|
||||
const flatParams = rows.flat();
|
||||
|
||||
// Upsert scores for each pair
|
||||
await pg.none(
|
||||
`
|
||||
INSERT INTO compatibility_scores (user_id_1, user_id_2, score)
|
||||
VALUES
|
||||
${values}
|
||||
ON CONFLICT (user_id_1, user_id_2)
|
||||
DO UPDATE SET score = EXCLUDED.score
|
||||
`,
|
||||
flatParams
|
||||
);
|
||||
|
||||
//
|
||||
// for (const otherId of otherUserIds) {
|
||||
// const answersOther = answersByUser[otherId] ?? []
|
||||
// if (!hasAnsweredQuestions(answersOther)) continue
|
||||
//
|
||||
// const {score} = getCompatibilityScore(answersSelf, answersOther)
|
||||
// const [u1, u2] = canonicalPair(userId, otherId)
|
||||
// await pg.none(
|
||||
// `insert into compatibility_scores (user_id_1, user_id_2, score)
|
||||
// values ($1, $2, $3)
|
||||
// on conflict (user_id_1, user_id_2)
|
||||
// do update set score = excluded.score`,
|
||||
// [u1, u2, adaptedScore]
|
||||
// )
|
||||
// }
|
||||
|
||||
const dt = Number(hrtime.bigint() - startTs) / 1e9
|
||||
console.log(`Done recomputing compatibility scores for user ${userId} (${dt.toFixed(1)}s).`)
|
||||
}
|
||||
@@ -8,9 +8,10 @@ import { getProfile } from 'shared/profiles/supabase'
|
||||
|
||||
export const createProfileLikeNotification = async (like: Row<'profile_likes'>) => {
|
||||
const { creator_id, target_id, like_id } = like
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const targetPrivateUser = await getPrivateUser(target_id)
|
||||
const profile = await getProfile(creator_id)
|
||||
const profile = await getProfile(creator_id, pg)
|
||||
|
||||
if (!targetPrivateUser || !profile) return
|
||||
|
||||
@@ -35,7 +36,6 @@ export const createProfileLikeNotification = async (like: Row<'profile_likes'>)
|
||||
sourceUserAvatarUrl: profile.pinned_url ?? profile.user.avatarUrl,
|
||||
sourceText: '',
|
||||
}
|
||||
const pg = createSupabaseDirectClient()
|
||||
return await insertNotificationToSupabase(notification, pg)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ export const createProfileShipNotification = async (
|
||||
|
||||
const creator = await getUser(creator_id)
|
||||
const targetPrivateUser = await getPrivateUser(recipientId)
|
||||
const profile = await getProfile(otherTargetId)
|
||||
const pg = createSupabaseDirectClient()
|
||||
const profile = await getProfile(otherTargetId, pg)
|
||||
|
||||
if (!creator || !targetPrivateUser || !profile) {
|
||||
console.error('Could not load user object', {
|
||||
@@ -86,6 +87,5 @@ export const createProfileShipNotification = async (
|
||||
otherTargetId,
|
||||
},
|
||||
}
|
||||
const pg = createSupabaseDirectClient()
|
||||
return await insertNotificationToSupabase(notification, pg)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { areGenderCompatible } from 'common/profiles/compatibility-util'
|
||||
import { type Profile, type ProfileRow } from 'common/profiles/profile'
|
||||
import { type User } from 'common/user'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import {areGenderCompatible} from 'common/profiles/compatibility-util'
|
||||
import {type Profile, type ProfileRow} from 'common/profiles/profile'
|
||||
import {type User} from 'common/user'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export type ProfileAndUserRow = ProfileRow & {
|
||||
name: string
|
||||
@@ -17,7 +17,7 @@ export function convertRow(row: ProfileAndUserRow | undefined): Profile | null {
|
||||
// Remove internal/search-only fields from the returned profile row
|
||||
const profile: any = {
|
||||
...row,
|
||||
user: { ...row.user, name: row.name, username: row.username } as User,
|
||||
user: {...row.user, name: row.name, username: row.username} as User,
|
||||
}
|
||||
delete profile.bio_text
|
||||
delete profile.bio_tsv
|
||||
@@ -26,18 +26,15 @@ export function convertRow(row: ProfileAndUserRow | undefined): Profile | null {
|
||||
|
||||
const PROFILE_COLS = 'profiles.*, name, username, users.data as user'
|
||||
|
||||
export const getProfile = async (userId: string) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
export const getProfile = async (userId: string, client?: SupabaseDirectClient) => {
|
||||
const pg = client ?? createSupabaseDirectClient()
|
||||
return await pg.oneOrNone(
|
||||
`
|
||||
select
|
||||
${PROFILE_COLS}
|
||||
from
|
||||
profiles
|
||||
join
|
||||
users on users.id = profiles.user_id
|
||||
where
|
||||
user_id = $1
|
||||
select ${PROFILE_COLS}
|
||||
from profiles
|
||||
join
|
||||
users on users.id = profiles.user_id
|
||||
where user_id = $1
|
||||
`,
|
||||
[userId],
|
||||
convertRow
|
||||
@@ -48,14 +45,11 @@ export const getProfiles = async (userIds: string[]) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
return await pg.map(
|
||||
`
|
||||
select
|
||||
${PROFILE_COLS}
|
||||
from
|
||||
profiles
|
||||
join
|
||||
users on users.id = profiles.user_id
|
||||
where
|
||||
user_id = any($1)
|
||||
select ${PROFILE_COLS}
|
||||
from profiles
|
||||
join
|
||||
users on users.id = profiles.user_id
|
||||
where user_id = any ($1)
|
||||
`,
|
||||
[userIds],
|
||||
convertRow
|
||||
@@ -66,19 +60,17 @@ export const getGenderCompatibleProfiles = async (profile: ProfileRow) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const profiles = await pg.map(
|
||||
`
|
||||
select
|
||||
${PROFILE_COLS}
|
||||
from profiles
|
||||
join
|
||||
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 profiles.pinned_url is not null
|
||||
`,
|
||||
{ ...profile },
|
||||
select ${PROFILE_COLS}
|
||||
from profiles
|
||||
join
|
||||
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 profiles.pinned_url is not null
|
||||
`,
|
||||
{...profile},
|
||||
convertRow
|
||||
)
|
||||
return profiles.filter((l: Profile) => areGenderCompatible(profile, l))
|
||||
@@ -91,31 +83,30 @@ export const getCompatibleProfiles = async (
|
||||
const pg = createSupabaseDirectClient()
|
||||
return await pg.map(
|
||||
`
|
||||
select
|
||||
${PROFILE_COLS}
|
||||
from profiles
|
||||
join
|
||||
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)
|
||||
select ${PROFILE_COLS}
|
||||
from profiles
|
||||
join
|
||||
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)
|
||||
|
||||
-- Gender
|
||||
and (profiles.gender = any($(pref_gender)) or profiles.gender = 'non-binary')
|
||||
and ($(gender) = any(profiles.pref_gender) or $(gender) = 'non-binary')
|
||||
-- Gender
|
||||
and (profiles.gender = any ($(pref_gender)) or profiles.gender = 'non-binary')
|
||||
and ($(gender) = any (profiles.pref_gender) or $(gender) = 'non-binary')
|
||||
|
||||
-- Age
|
||||
and profiles.age >= $(pref_age_min)
|
||||
and profiles.age <= $(pref_age_max)
|
||||
and $(age) >= profiles.pref_age_min
|
||||
and $(age) <= profiles.pref_age_max
|
||||
-- Age
|
||||
and profiles.age >= $(pref_age_min)
|
||||
and profiles.age <= $(pref_age_max)
|
||||
and $(age) >= profiles.pref_age_min
|
||||
and $(age) <= profiles.pref_age_max
|
||||
|
||||
-- Location
|
||||
and calculate_earth_distance_km($(city_latitude), $(city_longitude), profiles.city_latitude, profiles.city_longitude) < $(radiusKm)
|
||||
`,
|
||||
{ ...profile, radiusKm: radiusKm ?? 40_000 },
|
||||
-- Location
|
||||
and calculate_earth_distance_km($(city_latitude), $(city_longitude), profiles.city_latitude,
|
||||
profiles.city_longitude) < $(radiusKm)
|
||||
`,
|
||||
{...profile, radiusKm: radiusKm ?? 40_000},
|
||||
convertRow
|
||||
)
|
||||
}
|
||||
@@ -124,8 +115,9 @@ export const getCompatibilityAnswers = async (userIds: string[]) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
return await pg.manyOrNone<Row<'compatibility_answers'>>(
|
||||
`
|
||||
select * from compatibility_answers
|
||||
where creator_id = any($1)
|
||||
select *
|
||||
from compatibility_answers
|
||||
where creator_id = any ($1)
|
||||
`,
|
||||
[userIds]
|
||||
)
|
||||
|
||||
@@ -77,6 +77,7 @@ export function createSupabaseDirectClient(
|
||||
password?: string
|
||||
) {
|
||||
if (pgpDirect) return pgpDirect
|
||||
console.log('Creating Supabase direct client')
|
||||
instanceId = instanceId ?? getInstanceId()
|
||||
if (!instanceId) {
|
||||
throw new Error(
|
||||
|
||||
@@ -61,8 +61,9 @@ export function from(clause: string, formatValues?: any) {
|
||||
return buildSql({ from })
|
||||
}
|
||||
|
||||
export function join(clause: string) {
|
||||
return buildSql({ join: clause })
|
||||
export function join(clause: string, formatValues?: any) {
|
||||
const join = pgp.as.format(clause, formatValues)
|
||||
return buildSql({ join })
|
||||
}
|
||||
|
||||
export function leftJoin(clause: string, formatValues?: any) {
|
||||
|
||||
57
backend/supabase/compatibility_scores.sql
Normal file
57
backend/supabase/compatibility_scores.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- Precomputed compatibility scores between pairs of users
|
||||
CREATE TABLE IF NOT EXISTS compatibility_scores (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
modified_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
user_id_1 TEXT NOT NULL, --- lowest-id user
|
||||
user_id_2 TEXT NOT NULL, --- highest-id user
|
||||
-- geometric mean score in range [0,1]
|
||||
score DOUBLE PRECISION
|
||||
);
|
||||
|
||||
-- Ensure canonical ordering and uniqueness of pairs
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS compatibility_scores_user_pair_unique
|
||||
ON public.compatibility_scores (user_id_1, user_id_2);
|
||||
|
||||
-- Foreign keys
|
||||
ALTER TABLE compatibility_scores
|
||||
ADD CONSTRAINT compatibility_scores_user_id_1_fkey
|
||||
FOREIGN KEY (user_id_1)
|
||||
REFERENCES users(id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE compatibility_scores
|
||||
ADD CONSTRAINT compatibility_scores_user_id_2_fkey
|
||||
FOREIGN KEY (user_id_2)
|
||||
REFERENCES users(id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- Row Level Security
|
||||
ALTER TABLE compatibility_scores ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Public read policy (scores are not sensitive by themselves)
|
||||
DROP POLICY IF EXISTS "public read" ON compatibility_scores;
|
||||
CREATE POLICY "public read" ON compatibility_scores
|
||||
FOR SELECT USING (true);
|
||||
|
||||
-- Update modified_time on any row update
|
||||
CREATE OR REPLACE FUNCTION set_modified_time_compat_scores()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.modified_time = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS set_modified_time_compat_scores_trigger ON compatibility_scores;
|
||||
CREATE TRIGGER set_modified_time_compat_scores_trigger
|
||||
BEFORE UPDATE ON compatibility_scores
|
||||
FOR EACH ROW EXECUTE FUNCTION set_modified_time_compat_scores();
|
||||
|
||||
-- Helpful indexes
|
||||
CREATE INDEX IF NOT EXISTS compatibility_scores_score_desc_idx
|
||||
ON public.compatibility_scores (score DESC);
|
||||
CREATE INDEX IF NOT EXISTS compatibility_scores_user1_idx
|
||||
ON public.compatibility_scores (user_id_1);
|
||||
CREATE INDEX IF NOT EXISTS compatibility_scores_user2_idx
|
||||
ON public.compatibility_scores (user_id_2);
|
||||
@@ -11,6 +11,7 @@ BEGIN;
|
||||
\i backend/supabase/compatibility_answers_free.sql
|
||||
\i backend/supabase/profile_comments.sql
|
||||
\i backend/supabase/compatibility_answers.sql
|
||||
\i backend/supabase/compatibility_scores.sql
|
||||
\i backend/supabase/profile_likes.sql
|
||||
\i backend/supabase/compatibility_prompts.sql
|
||||
\i backend/supabase/profile_ships.sql
|
||||
|
||||
@@ -273,8 +273,8 @@ export const API = (_apiTypeCheck = {
|
||||
rateLimited: true,
|
||||
props: z.object({userId: z.string()}),
|
||||
returns: {} as {
|
||||
profile: Profile
|
||||
compatibleProfiles: Profile[]
|
||||
// profile: Profile
|
||||
// compatibleProfiles: Profile[]
|
||||
profileCompatibilityScores: {
|
||||
[userId: string]: CompatibilityScore
|
||||
}
|
||||
@@ -358,6 +358,9 @@ export const API = (_apiTypeCheck = {
|
||||
props: z.object({
|
||||
id: z.number(),
|
||||
}),
|
||||
returns: {} as {
|
||||
status: 'success'
|
||||
},
|
||||
summary: 'Delete a compatibility answer',
|
||||
tag: 'Compatibility',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { keyBy, sumBy } from 'lodash'
|
||||
import { ProfileRow } from 'common/profiles/profile'
|
||||
import { Row as rowFor } from 'common/supabase/utils'
|
||||
import {keyBy, sumBy} from 'lodash'
|
||||
import {ProfileRow} from 'common/profiles/profile'
|
||||
import {Row as rowFor} from 'common/supabase/utils'
|
||||
import {
|
||||
areAgeCompatible,
|
||||
areLocationCompatible,
|
||||
@@ -24,15 +24,8 @@ export const getCompatibilityScore = (
|
||||
answers1: rowFor<'compatibility_answers'>[],
|
||||
answers2: rowFor<'compatibility_answers'>[]
|
||||
): CompatibilityScore => {
|
||||
const {
|
||||
score: score1,
|
||||
maxScore: maxScore1,
|
||||
answerCount,
|
||||
} = getAnswersCompatibility(answers1, answers2)
|
||||
const { score: score2, maxScore: maxScore2 } = getAnswersCompatibility(
|
||||
answers2,
|
||||
answers1
|
||||
)
|
||||
const {score: score1, maxScore: maxScore1, answerCount} = getAnswersCompatibility(answers1, answers2)
|
||||
const {score: score2, maxScore: maxScore2} = getAnswersCompatibility(answers2, answers1)
|
||||
|
||||
// >=100 answers in common leads to no weight toward 50%.
|
||||
// Use sqrt for diminishing returns to answering more questions.
|
||||
@@ -49,7 +42,7 @@ export const getCompatibilityScore = (
|
||||
const confidence =
|
||||
answerCount < 10 ? 'low' : answerCount < 100 ? 'medium' : 'high'
|
||||
|
||||
return { score: geometricMean, confidence }
|
||||
return {score: geometricMean, confidence}
|
||||
}
|
||||
|
||||
const getAnswersCompatibility = (
|
||||
@@ -73,7 +66,7 @@ const getAnswersCompatibility = (
|
||||
return getAnswerCompatibilityImportanceScore(a, answer2)
|
||||
})
|
||||
|
||||
return { score, maxScore, answerCount }
|
||||
return {score, maxScore, answerCount}
|
||||
}
|
||||
|
||||
export function getAnswerCompatibilityImportanceScore(
|
||||
|
||||
@@ -134,6 +134,7 @@ export type Database = {
|
||||
compatibility_prompts: {
|
||||
Row: {
|
||||
answer_type: string
|
||||
category: string | null
|
||||
created_time: string
|
||||
creator_id: string | null
|
||||
id: number
|
||||
@@ -143,6 +144,7 @@ export type Database = {
|
||||
}
|
||||
Insert: {
|
||||
answer_type?: string
|
||||
category?: string | null
|
||||
created_time?: string
|
||||
creator_id?: string | null
|
||||
id?: number
|
||||
@@ -152,6 +154,7 @@ export type Database = {
|
||||
}
|
||||
Update: {
|
||||
answer_type?: string
|
||||
category?: string | null
|
||||
created_time?: string
|
||||
creator_id?: string | null
|
||||
id?: number
|
||||
@@ -169,6 +172,48 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
compatibility_scores: {
|
||||
Row: {
|
||||
created_time: string
|
||||
id: number
|
||||
modified_time: string
|
||||
score: number | null
|
||||
user_id_1: string
|
||||
user_id_2: string
|
||||
}
|
||||
Insert: {
|
||||
created_time?: string
|
||||
id?: never
|
||||
modified_time?: string
|
||||
score?: number | null
|
||||
user_id_1: string
|
||||
user_id_2: string
|
||||
}
|
||||
Update: {
|
||||
created_time?: string
|
||||
id?: never
|
||||
modified_time?: string
|
||||
score?: number | null
|
||||
user_id_1?: string
|
||||
user_id_2?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'compatibility_scores_user_id_1_fkey'
|
||||
columns: ['user_id_1']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'users'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'compatibility_scores_user_id_2_fkey'
|
||||
columns: ['user_id_2']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'users'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
]
|
||||
}
|
||||
contact: {
|
||||
Row: {
|
||||
content: Json | null
|
||||
|
||||
@@ -43,6 +43,8 @@ import {useIsLooking} from 'web/hooks/use-is-looking'
|
||||
import {DropdownButton} from '../filters/desktop-filters'
|
||||
import {buildArray} from 'common/util/array'
|
||||
import toast from "react-hot-toast";
|
||||
import {useCompatibleProfiles} from "web/hooks/use-profiles";
|
||||
import {CompatibleBadge} from "web/components/widgets/compatible-badge";
|
||||
|
||||
const NUM_QUESTIONS_TO_SHOW = 8
|
||||
|
||||
@@ -83,6 +85,10 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
}) {
|
||||
const {isCurrentUser, user, fromSignup, fromProfilePage, profile} = props
|
||||
|
||||
const currentUser = useUser()
|
||||
const compatibleProfiles = useCompatibleProfiles(currentUser?.id)
|
||||
const compatibilityScore = compatibleProfiles?.profileCompatibilityScores?.[profile.user_id]
|
||||
|
||||
const {refreshCompatibilityQuestions, compatibilityQuestions} = useCompatibilityQuestionsWithAnswerCount()
|
||||
|
||||
const {refreshCompatibilityAnswers, compatibilityAnswers} = useUserCompatibilityAnswers(user.id)
|
||||
@@ -101,10 +107,10 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
)
|
||||
|
||||
const {skippedQuestions, answeredQuestions, otherQuestions} = separateQuestionsArray(
|
||||
compatibilityQuestions,
|
||||
skippedAnswerQuestionIds,
|
||||
answeredQuestionIds
|
||||
)
|
||||
compatibilityQuestions,
|
||||
skippedAnswerQuestionIds,
|
||||
answeredQuestionIds
|
||||
)
|
||||
|
||||
const refreshCompatibilityAll = () => {
|
||||
refreshCompatibilityAnswers()
|
||||
@@ -117,7 +123,6 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
`compatibility-sort-${user.id}`
|
||||
)
|
||||
|
||||
const currentUser = useUser()
|
||||
const comparedUserId = fromProfilePage?.user_id ?? currentUser?.id
|
||||
const {compatibilityAnswers: comparedAnswers} = useUserCompatibilityAnswers(comparedUserId)
|
||||
const questionIdToComparedAnswer = keyBy(comparedAnswers, 'question_id')
|
||||
@@ -169,12 +174,17 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
return (
|
||||
<Col className="gap-4">
|
||||
<Row className="flex-wrap items-center justify-between gap-x-6 gap-y-4">
|
||||
<Subtitle>{`${
|
||||
isCurrentUser ? 'Your' : shortenName(user.name) + `'s`
|
||||
} Compatibility Prompts`}</Subtitle>
|
||||
<Row className={'gap-8'}>
|
||||
<Subtitle>{`${
|
||||
isCurrentUser ? 'Your' : shortenName(user.name) + `'s`
|
||||
} Compatibility Prompts`}</Subtitle>
|
||||
{compatibilityScore &&
|
||||
<CompatibleBadge compatibility={compatibilityScore} className={'mt-4 mr-4'}/>
|
||||
}
|
||||
</Row>
|
||||
{(!isCurrentUser || fromProfilePage) && (
|
||||
<CompatibilitySortWidget
|
||||
className="text-sm sm:flex"
|
||||
className="text-sm sm:flex mt-4"
|
||||
sort={sort}
|
||||
setSort={setSort}
|
||||
user={user}
|
||||
@@ -408,8 +418,11 @@ export function CompatibilityAnswerBlock(props: {
|
||||
onClick: () => {
|
||||
deleteCompatibilityAnswer(answer.id, user.id)
|
||||
.then(() => refreshCompatibilityAll())
|
||||
.catch((e) => {toast.error(e.message)})
|
||||
.finally(() => {})
|
||||
.catch((e) => {
|
||||
toast.error(e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
})
|
||||
},
|
||||
},
|
||||
]}
|
||||
@@ -427,9 +440,14 @@ export function CompatibilityAnswerBlock(props: {
|
||||
icon: <TrashIcon className="h-5 w-5"/>,
|
||||
onClick: () => {
|
||||
submitCompatibilityAnswer(getEmptyAnswer(user.id, question.id))
|
||||
.then(() => {refreshCompatibilityAll()})
|
||||
.catch((e) => {toast.error(e.message)})
|
||||
.finally(() => {})
|
||||
.then(() => {
|
||||
refreshCompatibilityAll()
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
})
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function ProfileInfo(props: {
|
||||
|
||||
const isProfileVisible = currentUser || profile.visibility === 'public'
|
||||
|
||||
const { data: userActivity } = useUserActivity(user?.id)
|
||||
const {data: userActivity} = useUserActivity(user?.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import { sortBy } from 'lodash'
|
||||
import { useEffect } from 'react'
|
||||
import { usePersistentInMemoryState } from 'web/hooks/use-persistent-in-memory-state'
|
||||
import { api } from 'web/lib/api'
|
||||
import { APIResponse } from 'common/api/schema'
|
||||
import { useProfileByUserId } from './use-profile'
|
||||
import { getProfilesCompatibilityFactor } from 'common/profiles/compatibility-score'
|
||||
import {useEffect} from 'react'
|
||||
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
|
||||
import {api} from 'web/lib/api'
|
||||
import {APIResponse} from 'common/api/schema'
|
||||
|
||||
export const useCompatibleProfiles = (
|
||||
userId: string | null | undefined,
|
||||
options?: { sortWithModifiers?: boolean }
|
||||
) => {
|
||||
const [data, setData] = usePersistentInMemoryState<
|
||||
APIResponse<'compatible-profiles'> | undefined | null
|
||||
>(undefined, `compatible-profiles-${userId}`)
|
||||
|
||||
const profile = useProfileByUserId(userId ?? undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
api('compatible-profiles', { userId })
|
||||
api('compatible-profiles', {userId})
|
||||
.then(setData)
|
||||
.catch((e) => {
|
||||
if (e.code === 404) {
|
||||
@@ -27,15 +21,10 @@ export const useCompatibleProfiles = (
|
||||
throw e
|
||||
}
|
||||
})
|
||||
} else if (userId === null) setData(null)
|
||||
} else if (userId === null) {
|
||||
setData(null)
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
if (data && profile && options?.sortWithModifiers) {
|
||||
data.compatibleProfiles = sortBy(data.compatibleProfiles, (l) => {
|
||||
const modifier = !profile ? 1 : getProfilesCompatibilityFactor(profile, l)
|
||||
return -1 * modifier * data.profileCompatibilityScores[l.user.id].score
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user