import * as Sentry from '@sentry/node' import {type APIHandler} from 'api/helpers/endpoint' import { DIET_CHOICES, EDUCATION_CHOICES, GENDERS, LANGUAGE_CHOICES, MBTI_CHOICES, POLITICAL_CHOICES, RACE_CHOICES, RELATIONSHIP_CHOICES, RELATIONSHIP_STATUS_CHOICES, RELIGION_CHOICES, ROMANTIC_CHOICES, } from 'common/choices' import {OptionTableKey} from 'common/profiles/constants' import {compact} from 'lodash' import {log} from 'shared/monitoring/log' import {convertRow} from 'shared/profiles/supabase' import {createSupabaseDirectClient, pgp} from 'shared/supabase/init' import { from, join, leftJoin, limit, orderBy, renderSql, select, where, } from 'shared/supabase/sql-builder' /** * Profile query parameters for filtering and pagination * * Defines the available filters and pagination options for retrieving * user profiles from the database. Supports complex filtering based * on demographics, preferences, and location. */ export type profileQueryType = { /** Maximum number of profiles to return */ limit?: number | undefined /** Pagination cursor for retrieving next page of results */ after?: string | undefined /** Specific user ID to retrieve (for single profile lookups) */ userId?: string | undefined /** Name filter for profile search */ name?: string | undefined /** Filter by gender identity */ genders?: string[] | undefined /** Filter for profiles with photos */ hasPhoto?: boolean | undefined /** Filter by education level */ education_levels?: string[] | undefined /** Filter by preferred gender for matches */ pref_gender?: string[] | undefined /** Minimum preferred age for matches */ pref_age_min?: number | undefined /** Maximum preferred age for matches */ pref_age_max?: number | undefined /** Minimum drinks consumed per month */ drinks_min?: number | undefined /** Maximum drinks consumed per month */ drinks_max?: number | undefined big5_openness_min?: number | undefined big5_openness_max?: number | undefined big5_conscientiousness_min?: number | undefined big5_conscientiousness_max?: number | undefined big5_extraversion_min?: number | undefined big5_extraversion_max?: number | undefined big5_agreeableness_min?: number | undefined big5_agreeableness_max?: number | undefined big5_neuroticism_min?: number | undefined big5_neuroticism_max?: number | undefined pref_relation_styles?: string[] | undefined pref_romantic_styles?: string[] | undefined diet?: string[] | undefined political_beliefs?: string[] | undefined mbti?: string[] | undefined relationship_status?: string[] | undefined languages?: string[] | undefined religion?: string[] | undefined wants_kids_strength?: number | undefined has_kids?: number | undefined is_smoker?: boolean | undefined shortBio?: boolean | undefined psychedelics?: string[] | undefined cannabis?: string[] | undefined psychedelics_intention?: string[] | undefined cannabis_intention?: string[] | undefined psychedelics_pref?: string[] | undefined cannabis_pref?: string[] | undefined geodbCityIds?: string[] | undefined lat?: number | undefined lon?: number | undefined radius?: number | undefined raised_in_lat?: number | undefined raised_in_lon?: number | undefined raised_in_radius?: number | undefined compatibleWithUserId?: string | undefined skipId?: string | undefined orderBy?: string | undefined lastModificationWithin?: string | undefined last_active?: string | undefined locale?: string | undefined } & { [K in OptionTableKey]?: string[] | undefined } // Define all text fields to search const textFields = [ 'users.name', 'search_text', 'headline', 'occupation', 'occupation_title', 'company', 'university', 'city', 'country', 'born_in_location', 'raised_in_city', 'raised_in_country', 'political_details', 'religious_beliefs', ] // Define choice fields to search const choiceFields = [ {field: 'gender', choices: GENDERS}, {field: 'education_level', choices: EDUCATION_CHOICES}, {field: 'mbti', choices: MBTI_CHOICES}, ] // Define array choice fields to search const arrayChoiceFields = [ {field: 'diet', choices: DIET_CHOICES}, {field: 'political_beliefs', choices: POLITICAL_CHOICES}, {field: 'relationship_status', choices: RELATIONSHIP_STATUS_CHOICES}, {field: 'religion', choices: RELIGION_CHOICES}, {field: 'pref_relation_styles', choices: RELATIONSHIP_CHOICES}, {field: 'pref_romantic_styles', choices: ROMANTIC_CHOICES}, {field: 'languages', choices: LANGUAGE_CHOICES}, {field: 'ethnicity', choices: RACE_CHOICES}, ] // const userActivityColumns = ['last_online_time'] export const loadProfiles = async (props: profileQueryType) => { const pg = createSupabaseDirectClient() log('get-profiles', props) const { limit: limitParam, after, name, userId, genders, hasPhoto, education_levels, pref_gender, pref_age_min, pref_age_max, drinks_min, drinks_max, big5_openness_min, big5_openness_max, big5_conscientiousness_min, big5_conscientiousness_max, big5_extraversion_min, big5_extraversion_max, big5_agreeableness_min, big5_agreeableness_max, big5_neuroticism_min, big5_neuroticism_max, pref_relation_styles, pref_romantic_styles, diet, political_beliefs, mbti, relationship_status, languages, religion, wants_kids_strength, has_kids, interests, causes, work, is_smoker, shortBio, psychedelics, cannabis, psychedelics_intention, cannabis_intention, psychedelics_pref, cannabis_pref, geodbCityIds, lat, lon, radius, raised_in_lat, raised_in_lon, raised_in_radius, compatibleWithUserId, orderBy: orderByParam = 'created_time', lastModificationWithin, skipId, locale = 'en', last_active, } = props const filterLocation = lat && lon && radius const filterRaisedInLocation = raised_in_lat && raised_in_lon && raised_in_radius const keywords = name ? name .split(',') .map((q) => q.trim()) .filter(Boolean) : [] // console.debug('keywords:', keywords) if (orderByParam === 'compatibility_score' && !compatibleWithUserId) { console.error('Incompatible with user ID') throw Error('Incompatible with user ID') } const tablePrefix = orderByParam === 'compatibility_score' ? 'compatibility_scores' : orderByParam === 'last_online_time' ? 'user_activity' : 'profiles' const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id' // Need them in the output for the profile card even we don't filter by them const joinInterests = true // !!interests?.length const joinCauses = true // !!causes?.length const joinWork = true // !!work?.length // Pre-aggregated interests per profile function getManyToManyJoin(label: OptionTableKey) { return `( SELECT profile_${label}.profile_id, ARRAY_AGG(${label}.id ORDER BY ${label}.id) AS ${label} FROM profile_${label} JOIN ${label} ON ${label}.id = profile_${label}.option_id GROUP BY profile_${label}.profile_id ) profile_${label} ON profile_${label}.profile_id = profiles.id` } const interestsJoin = getManyToManyJoin('interests') const causesJoin = getManyToManyJoin('causes') const workJoin = getManyToManyJoin('work') 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 joins = [ (orderByParam === 'last_online_time' || last_active) && leftJoin(userActivityJoin), orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin), joinInterests && leftJoin(interestsJoin), joinCauses && leftJoin(causesJoin), joinWork && leftJoin(workJoin), ] const _orderBy = orderByParam === 'compatibility_score' ? 'cs.score' : `${tablePrefix}.${orderByParam}` const afterFilter = renderSql( select(_orderBy), from('profiles'), ...joins, where('profiles.id = $(after)', {after}), ) const tableSelection = compact([ from('profiles'), join('users on users.id = profiles.user_id'), ...joins, ]) function getManyToManyClause(label: OptionTableKey) { return `EXISTS ( SELECT 1 FROM profile_${label} JOIN ${label} ON ${label}.id = profile_${label}.option_id WHERE profile_${label}.profile_id = profiles.id AND ${label}.id = ANY (ARRAY[$(values)]) )` } function getOptionClauseKeyword(label: OptionTableKey) { return `EXISTS ( SELECT 1 FROM profile_${label} JOIN ${label} ON ${label}.id = profile_${label}.option_id LEFT JOIN ${label}_translations ON ${label}_translations.option_id = profile_${label}.option_id AND ${label}_translations.locale = $(locale) WHERE profile_${label}.profile_id = profiles.id AND lower(COALESCE(${label}_translations.name, ${label}.name)) ILIKE '%' || lower($(word)) || '%' )` } function getTextFieldClauseKeyword(field: string) { return `lower(${field}) ilike '%' || lower($(word)) || '%'` } // TODO: support locales (right now it only maps to english labels) function getChoiceFieldClauseKeyword(field: string, choices: Record) { const choiceValues = Object.entries(choices) .map(([key, value]) => `('${value}', '${key}')`) .join(', ') return `( ${getTextFieldClauseKeyword(field)} OR EXISTS ( SELECT 1 FROM (VALUES ${choiceValues}) AS t(choice_key, choice_value) WHERE t.choice_key = ${field} AND ${getTextFieldClauseKeyword('t.choice_value')} ) )` } // TODO: support locales (right now it only maps to english labels) function getArrayChoiceFieldClauseKeyword(arrayField: string, choices: Record) { const choiceValues = Object.entries(choices) .map(([key, value]) => `('${value}', '${key}')`) .join(', ') return `EXISTS ( SELECT 1 FROM unnest(${arrayField}) AS choice_key WHERE ${getTextFieldClauseKeyword('choice_key')} OR EXISTS ( SELECT 1 FROM (VALUES ${choiceValues}) AS t(choice_k, choice_value) WHERE t.choice_k = choice_key AND ${getTextFieldClauseKeyword('t.choice_value')} ) )` } const filters = [ where('looking_for_matches = true'), where(`profiles.disabled != true`), where(`not users.is_banned_from_posting`), // where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`), ...keywords.map((word) => where( [ // Text field searches ...textFields.map((field) => getTextFieldClauseKeyword(field)), // Full-text search "search_tsv @@ phraseto_tsquery('english', $(word))", // Option table searches with translations getOptionClauseKeyword('interests'), getOptionClauseKeyword('causes'), getOptionClauseKeyword('work'), // Choice field searches ...choiceFields.map(({field, choices}) => getChoiceFieldClauseKeyword(field, choices)), // Array choice field searches ...arrayChoiceFields.map(({field, choices}) => getArrayChoiceFieldClauseKeyword(field, choices), ), // Keywords search `EXISTS ( SELECT 1 FROM unnest(keywords) AS kw WHERE ${getTextFieldClauseKeyword('kw')} )`, ].join(' OR '), {word, locale}, ), ), genders?.length && where(`gender = ANY($(genders))`, {genders}), education_levels?.length && where(`education_level = ANY($(education_levels))`, {education_levels}), mbti?.length && where(`mbti = ANY($(mbti))`, {mbti}), pref_gender?.length && where(`pref_gender is NULL or pref_gender = '{}' OR pref_gender && $(pref_gender)`, { pref_gender, }), pref_age_min && where(`age >= $(pref_age_min) or age is null`, {pref_age_min}), pref_age_max && where(`age <= $(pref_age_max) or age is null`, {pref_age_max}), drinks_min && where(`drinks_per_month >= $(drinks_min) or drinks_per_month is null`, {drinks_min}), drinks_max && where(`drinks_per_month <= $(drinks_max) or drinks_per_month is null`, {drinks_max}), big5_openness_min && where(`big5_openness >= $(big5_openness_min) or big5_openness is null`, {big5_openness_min}), big5_openness_max && where(`big5_openness <= $(big5_openness_max) or big5_openness is null`, {big5_openness_max}), big5_conscientiousness_min && where( `big5_conscientiousness >= $(big5_conscientiousness_min) or big5_conscientiousness is null`, {big5_conscientiousness_min}, ), big5_conscientiousness_max && where( `big5_conscientiousness <= $(big5_conscientiousness_max) or big5_conscientiousness is null`, {big5_conscientiousness_max}, ), big5_extraversion_min && where(`big5_extraversion >= $(big5_extraversion_min) or big5_extraversion is null`, { big5_extraversion_min, }), big5_extraversion_max && where(`big5_extraversion <= $(big5_extraversion_max) or big5_extraversion is null`, { big5_extraversion_max, }), big5_agreeableness_min && where(`big5_agreeableness >= $(big5_agreeableness_min) or big5_agreeableness is null`, { big5_agreeableness_min, }), big5_agreeableness_max && where(`big5_agreeableness <= $(big5_agreeableness_max) or big5_agreeableness is null`, { big5_agreeableness_max, }), big5_neuroticism_min && where(`big5_neuroticism >= $(big5_neuroticism_min) or big5_neuroticism is null`, { big5_neuroticism_min, }), big5_neuroticism_max && where(`big5_neuroticism <= $(big5_neuroticism_max) or big5_neuroticism is null`, { big5_neuroticism_max, }), pref_relation_styles?.length && where( `pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`, {pref_relation_styles}, ), pref_romantic_styles?.length && where( `pref_romantic_styles IS NULL OR pref_romantic_styles = '{}' OR pref_romantic_styles && $(pref_romantic_styles)`, {pref_romantic_styles}, ), diet?.length && where(`diet IS NULL OR diet = '{}' OR diet && $(diet)`, {diet}), political_beliefs?.length && where( `political_beliefs IS NULL OR political_beliefs = '{}' OR political_beliefs && $(political_beliefs)`, {political_beliefs}, ), relationship_status?.length && where( `relationship_status IS NULL OR relationship_status = '{}' OR relationship_status && $(relationship_status)`, {relationship_status}, ), languages?.length && where(`languages && $(languages)`, {languages}), religion?.length && where(`religion IS NULL OR religion = '{}' OR religion && $(religion)`, {religion}), interests?.length && where(getManyToManyClause('interests'), {values: interests.map(Number)}), causes?.length && where(getManyToManyClause('causes'), {values: causes.map(Number)}), work?.length && where(getManyToManyClause('work'), {values: work.map(Number)}), !!wants_kids_strength && wants_kids_strength !== -1 && where( 'wants_kids_strength = -1 OR wants_kids_strength IS NULL OR ' + (wants_kids_strength >= 2 ? `wants_kids_strength >= $(wants_kids_strength)` : `wants_kids_strength <= $(wants_kids_strength)`), {wants_kids_strength}, ), has_kids === 0 && where(`has_kids IS NULL OR has_kids = 0`), has_kids && has_kids > 0 && where(`has_kids > 0`), is_smoker !== undefined && where( (is_smoker ? '' : 'is_smoker IS NULL OR ') + // smokers are rare, so we don't include the people who didn't answer if we're looking for smokers `is_smoker = $(is_smoker)`, {is_smoker}, ), psychedelics?.length && where( (psychedelics.includes('never_not_interested') ? 'psychedelics IS NULL OR ' : '') + `psychedelics = ANY($(psychedelics))`, {psychedelics}, ), cannabis?.length && where( (cannabis.includes('never_not_interested') ? 'cannabis IS NULL OR ' : '') + `cannabis = ANY($(cannabis))`, {cannabis}, ), psychedelics_intention?.length && where( `(psychedelics IS NOT NULL AND psychedelics != 'never_not_interested') AND (psychedelics_intention IS NULL OR psychedelics_intention = '{}') OR psychedelics_intention && $(psychedelics_intention)`, {psychedelics_intention}, ), cannabis_intention?.length && where( `(cannabis IS NOT NULL AND cannabis != 'never_not_interested') AND (cannabis_intention IS NULL OR cannabis_intention = '{}') OR cannabis_intention && $(cannabis_intention)`, {cannabis_intention}, ), psychedelics_pref?.length && where( `psychedelics_pref IS NULL OR psychedelics_pref = '{}' OR psychedelics_pref && $(psychedelics_pref)`, {psychedelics_pref}, ), cannabis_pref?.length && where(`cannabis_pref IS NULL OR cannabis_pref = '{}' OR cannabis_pref && $(cannabis_pref)`, { cannabis_pref, }), geodbCityIds?.length && where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}), // miles par degree of lat: earth's radius (3950 miles) * pi / 180 = 69.0 filterLocation && where( ` city_latitude BETWEEN $(target_lat) - ($(radius) / 69.0) AND $(target_lat) + ($(radius) / 69.0) AND city_longitude BETWEEN $(target_lon) - ($(radius) / (69.0 * COS(RADIANS($(target_lat))))) AND $(target_lon) + ($(radius) / (69.0 * COS(RADIANS($(target_lat))))) AND SQRT( POWER(city_latitude - $(target_lat), 2) + POWER((city_longitude - $(target_lon)) * COS(RADIANS($(target_lat))), 2) ) <= $(radius) / 69.0 `, {target_lat: lat, target_lon: lon, radius}, ), filterRaisedInLocation && where( ` raised_in_lat BETWEEN $(target_lat) - ($(radius) / 69.0) AND $(target_lat) + ($(radius) / 69.0) AND raised_in_lon BETWEEN $(target_lon) - ($(radius) / (69.0 * COS(RADIANS($(target_lat))))) AND $(target_lon) + ($(radius) / (69.0 * COS(RADIANS($(target_lat))))) AND SQRT( POWER(raised_in_lat - $(target_lat), 2) + POWER((raised_in_lon - $(target_lon)) * COS(RADIANS($(target_lat))), 2) ) <= $(radius) / 69.0 `, {target_lat: raised_in_lat, target_lon: raised_in_lon, radius: raised_in_radius}, ), skipId && where(`profiles.user_id != $(skipId)`, {skipId}), !shortBio && where( `bio_length >= ${100} OR headline IS NOT NULL OR array_length(profile_work.work, 1) > 0 OR array_length(profile_interests.interests, 1) > 0 OR occupation_title IS NOT NULL `, ), hasPhoto && where("pinned_url IS NOT NULL AND pinned_url != ''"), lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, { lastModificationWithin, }), last_active && where(`user_activity.last_online_time >= NOW() - INTERVAL $(last_active_interval)`, { last_active_interval: last_active === 'now' ? '30 minutes' : last_active === 'today' ? '1 day' : last_active === '3days' ? '3 days' : last_active === 'week' ? '7 days' : last_active === 'month' ? '30 days' : '90 days', }), // Exclude profiles that the requester has chosen to hide userId && where( `NOT EXISTS ( SELECT 1 FROM hidden_profiles hp WHERE hp.hider_user_id = $(userId) AND hp.hidden_user_id = profiles.user_id )`, {userId}, ), ] let selectCols = 'profiles.*, users.name, users.username, users.data as user' if (orderByParam === 'compatibility_score') { selectCols += ', cs.score as compatibility_score' } else if (orderByParam === 'last_online_time' || last_active) { selectCols += ', user_activity.last_online_time' } if (joinInterests) selectCols += `, COALESCE(profile_interests.interests, '{}') AS interests` if (joinCauses) selectCols += `, COALESCE(profile_causes.causes, '{}') AS causes` if (joinWork) selectCols += `, COALESCE(profile_work.work, '{}') AS work` const query = renderSql( select(selectCols), ...tableSelection, ...filters, orderBy(`${_orderBy} DESC`), after && where(`${_orderBy} < (${afterFilter})`), limitParam && limit(limitParam), ) // console.debug('query:', query) const profiles = await pg.map(query, [], convertRow) // console.debug('profiles:', profiles) const countQuery = renderSql(select(`count(*) as count`), ...tableSelection, ...filters) const count = await pg.one(countQuery, [], (r) => Number(r.count)) return {profiles, count} } export const getProfiles: APIHandler<'get-profiles'> = async (props, auth) => { try { if (!props.skipId) props.skipId = auth.uid const {profiles, count} = await loadProfiles({...props, userId: auth.uid}) return {status: 'success', profiles: profiles, count: count} } catch (error) { console.log(error) Sentry.captureException(error, {extra: props}) return {status: 'fail', profiles: [], count: 0} } }